mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(mail): add +draft-send shortcut for batch draft sending (#1017)
Add `lark-cli mail +draft-send` shortcut that takes one or more existing
draft IDs and sends each via POST /drafts/:draft_id/send sequentially.
Per-draft failures are isolated and aggregated into a structured output;
fatal failures (auth, permission, network, mailbox quota) abort the
entire batch immediately while recoverable failures honor --stop-on-error.
Also extend internal/output with six mail-send-specific errno constants
(LarkErrMailboxNotFound=4013, LarkErrMailSendQuota{User,UserExt,TenantExt},
LarkErrMailQuota, LarkErrTenantStorageLimit) consumed by isFatalSendErr.
Risk is "high-risk-write" so the framework's --yes gate applies; the
shortcut declares only the minimal mail:user_mailbox.message:send scope
to avoid asking users for permissions it does not need.
This commit is contained in:
@@ -66,6 +66,19 @@ const (
|
||||
|
||||
// IM resource ownership mismatch.
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
|
||||
// Mail send: account / mailbox-level failures returned by
|
||||
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
|
||||
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
|
||||
// because ErrAPI preserves Detail.Code exactly as returned by the server.
|
||||
// These codes indicate the entire batch will keep failing identically and
|
||||
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
|
||||
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
|
||||
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
|
||||
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
|
||||
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
|
||||
LarkErrMailQuota = 1236010 // mail quota limit
|
||||
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
|
||||
)
|
||||
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
|
||||
@@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
|
||||
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
|
||||
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
|
||||
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
|
||||
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
|
||||
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("code=%d, want %d", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
|
||||
// contention error (131009) maps to an actionable retry hint instead of
|
||||
// a generic "api_error". Surfaces during concurrent wiki +node-create
|
||||
|
||||
330
shortcuts/mail/mail_draft_send.go
Normal file
330
shortcuts/mail/mail_draft_send.go
Normal file
@@ -0,0 +1,330 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MaxBatchSendDrafts caps the number of draft IDs accepted in a single
|
||||
// +draft-send invocation. The limit is purely client-side: it bounds command-
|
||||
// line length comfortably below ARG_MAX and keeps the failure blast radius of
|
||||
// a single batch small. It is intentionally local to this shortcut (rather
|
||||
// than living in limits.go) because no other shortcut shares the semantics.
|
||||
const MaxBatchSendDrafts = 50
|
||||
|
||||
// sentDraft is the per-draft success entry in the +draft-send aggregated
|
||||
// output. message_id and thread_id come from the server response of
|
||||
// POST /drafts/:draft_id/send.
|
||||
type sentDraft struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
}
|
||||
|
||||
// failedDraft is the per-draft failure entry. error is the
|
||||
// human-readable err.Error() string (typically including ClassifyLarkError
|
||||
// hints); v2 may surface a structured errno field separately once the server-
|
||||
// side mapping stabilises (see tech-design "待确认事项").
|
||||
type failedDraft struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// batchSendOutput is the JSON envelope data shape:
|
||||
//
|
||||
// {
|
||||
// "mailbox_id": "me",
|
||||
// "total": 3,
|
||||
// "success_count": 2,
|
||||
// "failure_count": 1,
|
||||
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
|
||||
// "failed":[{"draft_id":..., "error":...}]
|
||||
// }
|
||||
//
|
||||
// failed is marked omitempty so a fully successful batch returns a clean shape
|
||||
// without an empty array.
|
||||
type batchSendOutput struct {
|
||||
MailboxID string `json:"mailbox_id"`
|
||||
Total int `json:"total"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
Sent []sentDraft `json:"sent"`
|
||||
Failed []failedDraft `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
|
||||
// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures.
|
||||
// Risk is "high-risk-write"; callers must pass --yes. User identity only —
|
||||
// drafts are user-owned resources and bot has no coherent semantics here.
|
||||
//
|
||||
// Output schema is the batchSendOutput type above. Partial failures (any
|
||||
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
|
||||
// agents can distinguish "all sent" from "some sent" without parsing the
|
||||
// success_count field.
|
||||
var MailDraftSend = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+draft-send",
|
||||
Description: "Send one or more existing mail drafts sequentially. Calls " +
|
||||
"POST /drafts/:draft_id/send for each input ID, isolates per-draft " +
|
||||
"failures, and aggregates the results. Use after the drafts have " +
|
||||
"already been created (via the Lark client, +draft-create, or the " +
|
||||
"drafts.create API).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."},
|
||||
{Name: "draft-id", Type: "string_slice", Required: true,
|
||||
Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."},
|
||||
{Name: "stop-on-error", Type: "bool",
|
||||
Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " +
|
||||
"Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " +
|
||||
"regardless of this flag."},
|
||||
},
|
||||
Validate: validateDraftSend,
|
||||
DryRun: dryRunDraftSend,
|
||||
Execute: executeDraftSend,
|
||||
}
|
||||
|
||||
// executeDraftSend runs the +draft-send command:
|
||||
//
|
||||
// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID).
|
||||
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
|
||||
// no empty elements).
|
||||
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
|
||||
// runtime.CallAPI. Per-draft outcomes:
|
||||
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
|
||||
// - recoverable err → append to failed[]; honor --stop-on-error.
|
||||
// - success + automation_send_disable signal → return immediately with
|
||||
// ExitAPI/"automation_send_disabled".
|
||||
// - success → append to sent[].
|
||||
// 4. Emit batchSendOutput via runtime.Out.
|
||||
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
|
||||
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveComposeMailboxID(rt)
|
||||
draftIDs, err := normalizedDraftSendIDs(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
|
||||
stopOnErr := rt.Bool("stop-on-error")
|
||||
for i, id := range draftIDs {
|
||||
idx := i + 1
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
|
||||
// a body, so the helper's send_time-aware envelope would add no value.
|
||||
data, err := rt.CallAPI("POST",
|
||||
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
|
||||
if err != nil {
|
||||
if isFatalSendErr(err) {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
hadProgress := out.hasProgress()
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
if hadProgress {
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// Account- / mailbox-level failures (auth, permission, network,
|
||||
// quota) will repeat identically for every remaining draft —
|
||||
// abort immediately so the caller sees a single clear error
|
||||
// instead of 100 redundant failed[] entries.
|
||||
return err
|
||||
}
|
||||
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
if stopOnErr {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reason := extractAutomationDisabledReason(data); reason != "" {
|
||||
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
|
||||
"automation send is disabled for this mailbox: %s", reason)
|
||||
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
if out.hasProgress() {
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// HTTP success (code: 0) but the backend signaled automation send
|
||||
// is disabled — every subsequent send will fail the same way, so
|
||||
// abort the batch with a single descriptive error.
|
||||
return err
|
||||
}
|
||||
s := sentDraft{DraftID: id}
|
||||
if v, ok := data["message_id"].(string); ok {
|
||||
s.MessageID = v
|
||||
}
|
||||
if v, ok := data["thread_id"].(string); ok {
|
||||
s.ThreadID = v
|
||||
}
|
||||
out.Sent = append(out.Sent, s)
|
||||
if s.MessageID != "" {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID))
|
||||
} else {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
}
|
||||
}
|
||||
emitDraftSendOutput(rt, &out)
|
||||
|
||||
if out.FailureCount == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "partial_failure",
|
||||
"%d of %d drafts failed to send", out.FailureCount, out.Total)
|
||||
}
|
||||
|
||||
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
|
||||
// in input order, with a header description summarising the batch size.
|
||||
func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveComposeMailboxID(rt)
|
||||
draftIDs, _ := normalizedDraftSendIDs(rt)
|
||||
api := common.NewDryRunAPI().Desc(fmt.Sprintf(
|
||||
"Send %d existing drafts sequentially", len(draftIDs)))
|
||||
for _, id := range draftIDs {
|
||||
api = api.POST(mailboxPath(mailboxID, "drafts", id, "send"))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizedDraftSendIDs(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
return normalizeDraftSendIDs(rt.StrSlice("draft-id"))
|
||||
}
|
||||
|
||||
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
|
||||
if len(draftIDs) == 0 {
|
||||
return nil, output.ErrValidation("--draft-id is required")
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(draftIDs))
|
||||
seen := make(map[string]struct{}, len(draftIDs))
|
||||
for _, id := range draftIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return nil, output.ErrValidation("--draft-id contains empty value")
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
if len(normalized) > MaxBatchSendDrafts {
|
||||
return nil, output.ErrValidation(
|
||||
"too many drafts: %d > %d (split into multiple batches)",
|
||||
len(normalized), MaxBatchSendDrafts)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (out *batchSendOutput) hasProgress() bool {
|
||||
return len(out.Sent) > 0 || len(out.Failed) > 0
|
||||
}
|
||||
|
||||
func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
|
||||
out.SuccessCount = len(out.Sent)
|
||||
out.FailureCount = len(out.Failed)
|
||||
rt.Out(*out, nil)
|
||||
}
|
||||
|
||||
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
|
||||
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// isFatalSendErr reports whether err is an account- or mailbox-level failure
|
||||
// that will repeat identically for every subsequent draft. Fatal errors
|
||||
// bypass --stop-on-error and immediately abort the batch.
|
||||
//
|
||||
// Trigger conditions:
|
||||
//
|
||||
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
|
||||
// unknown shapes are treated as fatal so they cannot accidentally
|
||||
// accumulate into failed[] for every remaining draft.
|
||||
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
|
||||
// "rate_limit", "network"}: token, scope, app-installation problems,
|
||||
// throttling, and connectivity are account-level.
|
||||
// - Code == output.ExitNetwork: connectivity loss is account-level.
|
||||
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
|
||||
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
|
||||
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
|
||||
// exhaustion is account-level.
|
||||
func isFatalSendErr(err error) bool {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return true
|
||||
}
|
||||
switch exitErr.Detail.Type {
|
||||
case "auth", "app_status", "config":
|
||||
return true
|
||||
case "permission", "rate_limit", "network":
|
||||
return true
|
||||
}
|
||||
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
|
||||
return true
|
||||
}
|
||||
switch exitErr.Detail.Code {
|
||||
case output.LarkErrMailboxNotFound,
|
||||
output.LarkErrMailSendQuotaUser,
|
||||
output.LarkErrMailSendQuotaUserExt,
|
||||
output.LarkErrMailSendQuotaTenantExt,
|
||||
output.LarkErrMailQuota,
|
||||
output.LarkErrTenantStorageLimit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wrapsExitCode(err error, code int) bool {
|
||||
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
|
||||
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractAutomationDisabledReason returns the human-readable reason when the
|
||||
// send succeeded at HTTP level (code: 0) but the backend reports that
|
||||
// automation send is disabled for this mailbox. An empty return value means
|
||||
// automation send is enabled.
|
||||
//
|
||||
// The data["automation_send_disable"] payload is best-effort: a malformed
|
||||
// shape or missing reason still produces a generic non-empty message so the
|
||||
// caller can surface the disabled status to the user instead of silently
|
||||
// continuing.
|
||||
func extractAutomationDisabledReason(data map[string]interface{}) string {
|
||||
ad, ok := data["automation_send_disable"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
m, ok := ad.(map[string]interface{})
|
||||
if !ok {
|
||||
return "automation send disabled (no reason provided)"
|
||||
}
|
||||
if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" {
|
||||
return strings.TrimSpace(reason)
|
||||
}
|
||||
return "automation send disabled (no reason provided)"
|
||||
}
|
||||
942
shortcuts/mail/mail_draft_send_test.go
Normal file
942
shortcuts/mail/mail_draft_send_test.go
Normal file
@@ -0,0 +1,942 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestMailDraftSend_Metadata pins the public surface of the +draft-send
|
||||
// shortcut: command name, risk level, scopes, auth type, and the three
|
||||
// declared flags. Changing any of these is a public-contract change and must
|
||||
// be intentional.
|
||||
func TestMailDraftSend_Metadata(t *testing.T) {
|
||||
if MailDraftSend.Service != "mail" {
|
||||
t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail")
|
||||
}
|
||||
if MailDraftSend.Command != "+draft-send" {
|
||||
t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send")
|
||||
}
|
||||
if MailDraftSend.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write")
|
||||
}
|
||||
if !MailDraftSend.HasFormat {
|
||||
t.Error("HasFormat must be true so --format is auto-injected")
|
||||
}
|
||||
if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes)
|
||||
}
|
||||
// Minimum-permission rule: only :send. Adding :modify or :readonly here is
|
||||
// an explicit scope-policy regression.
|
||||
if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes)
|
||||
}
|
||||
|
||||
flagByName := map[string]common.Flag{}
|
||||
for _, fl := range MailDraftSend.Flags {
|
||||
flagByName[fl.Name] = fl
|
||||
}
|
||||
mailbox, ok := flagByName["mailbox"]
|
||||
if !ok {
|
||||
t.Fatal("missing --mailbox flag")
|
||||
}
|
||||
if mailbox.Required {
|
||||
t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)")
|
||||
}
|
||||
if mailbox.Default != "" {
|
||||
t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default)
|
||||
}
|
||||
draftID, ok := flagByName["draft-id"]
|
||||
if !ok {
|
||||
t.Fatal("missing --draft-id flag")
|
||||
}
|
||||
if !draftID.Required {
|
||||
t.Error("--draft-id must be Required so cobra rejects missing-flag invocations")
|
||||
}
|
||||
if draftID.Type != "string_slice" {
|
||||
t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice")
|
||||
}
|
||||
stopOnErr, ok := flagByName["stop-on-error"]
|
||||
if !ok {
|
||||
t.Fatal("missing --stop-on-error flag")
|
||||
}
|
||||
if stopOnErr.Required {
|
||||
t.Error("--stop-on-error must be optional")
|
||||
}
|
||||
if stopOnErr.Type != "bool" {
|
||||
t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool")
|
||||
}
|
||||
}
|
||||
|
||||
// stubDraftSend registers a stub for POST .../drafts/<draftID>/send with the
|
||||
// supplied response body. Used to assemble multi-draft test scenarios.
|
||||
func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts/" + draftID + "/send",
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
|
||||
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
|
||||
// and exit code = 0 (err == nil).
|
||||
func TestMailDraftSend_AllSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"thread_id": "thread_1",
|
||||
},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_2",
|
||||
"thread_id": "thread_2",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil err on full success, got %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 2 {
|
||||
t.Errorf("total = %v, want 2", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 2 {
|
||||
t.Errorf("success_count = %v, want 2", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 0 {
|
||||
t.Errorf("failure_count = %v, want 0", data["failure_count"])
|
||||
}
|
||||
sent, ok := data["sent"].([]interface{})
|
||||
if !ok || len(sent) != 2 {
|
||||
t.Fatalf("sent[] missing or wrong size: %#v", data["sent"])
|
||||
}
|
||||
if _, exists := data["failed"]; exists {
|
||||
t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"])
|
||||
}
|
||||
first := sent[0].(map[string]interface{})
|
||||
if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" {
|
||||
t.Errorf("first sent entry shape unexpected: %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_ProgressWritesToStderr verifies long sends do not look
|
||||
// hung: per-draft progress is emitted on stderr while stdout remains the
|
||||
// final machine-readable JSON ledger.
|
||||
func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found",
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_3",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
|
||||
progress := stderr.String()
|
||||
for _, want := range []string{
|
||||
"mail +draft-send: [1/3] sending draft d1",
|
||||
"mail +draft-send: [1/3] sent draft d1 message_id=msg_1",
|
||||
"mail +draft-send: [2/3] sending draft d2",
|
||||
"mail +draft-send: [2/3] failed draft d2:",
|
||||
"mail +draft-send: [3/3] sending draft d3",
|
||||
"mail +draft-send: [3/3] sent draft d3 message_id=msg_3",
|
||||
} {
|
||||
if !strings.Contains(progress, want) {
|
||||
t.Errorf("stderr missing %q; got %s", want, progress)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "mail +draft-send:") {
|
||||
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("unexpected aggregate counts: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
|
||||
// failure does not abort the batch; the remaining drafts are attempted; both
|
||||
// arrays are populated; and the call returns ExitAPI/"partial_failure".
|
||||
func TestMailDraftSend_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
// Non-fatal code (not in the {auth, app_status, config, permission,
|
||||
// network, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013}
|
||||
// set) → recoverable.
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found or already sent",
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_3"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
|
||||
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 2 {
|
||||
t.Errorf("success_count = %v, want 2", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
failed, ok := data["failed"].([]interface{})
|
||||
if !ok || len(failed) != 1 {
|
||||
t.Fatalf("failed[] missing or wrong size: %#v", data["failed"])
|
||||
}
|
||||
failedEntry := failed[0].(map[string]interface{})
|
||||
if failedEntry["draft_id"] != "d2" {
|
||||
t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"])
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") {
|
||||
t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the
|
||||
// first recoverable failure. d3 is intentionally NOT stubbed: if the loop
|
||||
// kept going, the httpmock RoundTripper would return "no stub for POST
|
||||
// /user_mailboxes/me/drafts/d3/send" and Execute would surface it.
|
||||
func TestMailDraftSend_StopOnError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
"--stop-on-error",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
|
||||
// found) aborts the batch immediately and does NOT populate failed[]; the
|
||||
// later drafts are not attempted (d2 is intentionally not stubbed — any
|
||||
// attempt would be observable as a runner failure from the httpmock layer).
|
||||
func TestMailDraftSend_FatalAborts(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": output.LarkErrMailboxNotFound,
|
||||
"msg": "mailbox not found",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
|
||||
}
|
||||
// No JSON envelope on stdout because Execute returned early before rt.Out.
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
|
||||
// after earlier side effects still emits the aggregate stdout ledger before
|
||||
// returning the fatal stderr error. This lets callers avoid blindly retrying a
|
||||
// draft that was already sent.
|
||||
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": output.LarkErrMailSendQuotaUser,
|
||||
"msg": "user daily send count exceeded",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
|
||||
t.Errorf("failed[0].draft_id = %q, want d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
|
||||
// carrying the automation_send_disable signal aborts the batch with
|
||||
// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent
|
||||
// drafts (d2 intentionally has no stub — any attempt would surface as an
|
||||
// httpmock "no stub" failure).
|
||||
func TestMailDraftSend_AutomationDisabled(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"automation_send_disable": map[string]interface{}{
|
||||
"reason": "policy: outbound automation disabled",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
|
||||
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
|
||||
// automation-send policy stop after earlier successful sends still writes the
|
||||
// batch ledger to stdout before returning the structured fatal error.
|
||||
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_2",
|
||||
"automation_send_disable": map[string]interface{}{
|
||||
"reason": "policy: outbound automation disabled",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
|
||||
t.Errorf("failed[0].draft_id = %q, want d2", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") {
|
||||
t.Errorf("failed[0].error should contain reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_ValidateErrors verifies that input-shape problems are
|
||||
// caught in the pre-call layers (cobra Required + Validate). No network call
|
||||
// is registered; the test should fail loudly if any HTTP call is attempted
|
||||
// (httpmock returns "no stub" in that case).
|
||||
func TestMailDraftSend_ValidateErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
wantCobra bool // true → cobra-level MarkFlagRequired error path
|
||||
}{
|
||||
{
|
||||
name: "missing draft-id",
|
||||
args: []string{"+draft-send", "--yes"},
|
||||
wantSub: `required flag(s) "draft-id" not set`,
|
||||
wantCobra: true,
|
||||
},
|
||||
{
|
||||
// cobra's StringSlice treats a bare "" as an unset flag, so pass a
|
||||
// whitespace-only element instead to drive the Validate-callback
|
||||
// empty-element branch.
|
||||
name: "whitespace-only value",
|
||||
args: []string{"+draft-send", "--draft-id", " ", "--yes"},
|
||||
wantSub: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"},
|
||||
wantSub: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "duplicate value",
|
||||
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"},
|
||||
wantSub: "--draft-id contains duplicate value: d1",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantSub) {
|
||||
t.Errorf("err = %v, want substring %q", err, c.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailDraftSend_DryRunValidateErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only value",
|
||||
args: []string{"+draft-send", "--draft-id", " ", "--dry-run"},
|
||||
wantSub: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"},
|
||||
wantSub: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "duplicate value",
|
||||
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"},
|
||||
wantSub: "--draft-id contains duplicate value: d1",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantSub) {
|
||||
t.Errorf("err = %v, want substring %q", err, c.wantSub)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected no dry-run output on validation error, got %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the
|
||||
// >MaxBatchSendDrafts validation branch without bloating the test file with a
|
||||
// hand-written list.
|
||||
func manyDraftIDs(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = "d" + strings.Repeat("x", 1) + intToString(i)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// intToString avoids the strconv import noise for a tiny test helper.
|
||||
func intToString(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
// TestMailDraftSend_MissingYes verifies the framework's high-risk-write
|
||||
// confirmation gate triggers ExitConfirmationRequired (10) when --yes is
|
||||
// omitted, before Execute is called.
|
||||
func TestMailDraftSend_MissingYes(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected ExitConfirmationRequired, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input
|
||||
// order and does NOT touch the network.
|
||||
func TestMailDraftSend_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", " d1 , d2 ",
|
||||
"--draft-id", " d3 ",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
s := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/drafts/d1/send`,
|
||||
`/user_mailboxes/me/drafts/d2/send`,
|
||||
`/user_mailboxes/me/drafts/d3/send`,
|
||||
`"method"`,
|
||||
`"POST"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("dry-run output missing %q; got %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_NormalizesDraftIDs verifies request paths and output use
|
||||
// trimmed draft IDs rather than preserving CLI whitespace.
|
||||
func TestMailDraftSend_NormalizesDraftIDs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_2"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", " d1 , d2 ",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" {
|
||||
t.Errorf("sent[1].draft_id = %q, want d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a
|
||||
// hand-built RuntimeContext so the dry-run plan can be inspected without the
|
||||
// full Mount pipeline. Useful for catching path-encoding regressions in
|
||||
// mailboxPath().
|
||||
func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) {
|
||||
rt := runtimeForMailDraftSendTest(t, map[string]string{
|
||||
"mailbox": "alice@example.com",
|
||||
}, []string{"d1", "d2"})
|
||||
api := dryRunDraftSend(context.Background(), rt)
|
||||
raw, err := json.Marshal(api)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run failed: %v", err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/alice@example.com/drafts/d1/send`,
|
||||
`/user_mailboxes/alice@example.com/drafts/d2/send`,
|
||||
`"method":"POST"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("dry-run JSON missing %q; got %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft-
|
||||
// send flag set so the DryRun callback can be exercised directly. Mirrors
|
||||
// runtimeForMailDeclineReceiptDryRun.
|
||||
func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("mailbox", "", "")
|
||||
cmd.Flags().StringSlice("draft-id", nil, "")
|
||||
cmd.Flags().Bool("stop-on-error", false, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("parse flags failed: %v", err)
|
||||
}
|
||||
for k, v := range strFlags {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set flag --%s failed: %v", k, err)
|
||||
}
|
||||
}
|
||||
for _, id := range draftIDs {
|
||||
if err := cmd.Flags().Set("draft-id", id); err != nil {
|
||||
t.Fatalf("set draft-id failed: %v", err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls
|
||||
// through to "me" via resolveComposeMailboxID, and the output reflects it.
|
||||
func TestMailDraftSend_MailboxFallback(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["mailbox_id"] != "me" {
|
||||
t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports
|
||||
// both the repeated-flag form (--draft-id d1 --draft-id d2) and the
|
||||
// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation.
|
||||
func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_2"},
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_3"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--draft-id", "d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 3 {
|
||||
t.Errorf("success_count = %v, want 3", data["success_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsFatalSendErr is a focused unit test for the classifier. Covers every
|
||||
// branch documented in the doc comment so future tweaks immediately surface
|
||||
// mis-categorisation.
|
||||
func TestIsFatalSendErr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil-like / unknown shape → fatal",
|
||||
err: errors.New("raw network panic surfaced unwrapped"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ExitError without Detail → fatal",
|
||||
err: &output.ExitError{Code: output.ExitInternal},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auth → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "app_status → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "config → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permission → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "rate_limit → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ExitNetwork → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitNetwork,
|
||||
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ExitNetwork → fatal",
|
||||
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailboxNotFound → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailSendQuotaUser → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrTenantStorageLimit → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic api_error → recoverable",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := isFatalSendErr(c.err)
|
||||
if got != c.want {
|
||||
t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractAutomationDisabledReason verifies all branches of the helper:
|
||||
// missing key → "", malformed map → generic message, empty/whitespace reason
|
||||
// → generic message, non-empty reason → trimmed value.
|
||||
func TestExtractAutomationDisabledReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{"missing key", map[string]interface{}{"message_id": "x"}, ""},
|
||||
{"non-map value", map[string]interface{}{
|
||||
"automation_send_disable": "not a map",
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"map but no reason", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{},
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"reason empty", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{"reason": " "},
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"reason populated", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{"reason": " policy block "},
|
||||
}, "policy block"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractAutomationDisabledReason(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string {
|
||||
t.Helper()
|
||||
items, ok := data[arrayKey].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey])
|
||||
}
|
||||
if index >= len(items) {
|
||||
t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items))
|
||||
}
|
||||
item, ok := items[index].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index])
|
||||
}
|
||||
value, ok := item[field].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut {
|
||||
MailReplyAll,
|
||||
MailSend,
|
||||
MailDraftCreate,
|
||||
MailDraftSend,
|
||||
MailDraftEdit,
|
||||
MailForward,
|
||||
MailSendReceipt,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Mail CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 62 leaf commands
|
||||
- Covered: 13
|
||||
- Coverage: 21.0%
|
||||
- Denominator: 63 leaf commands
|
||||
- Covered: 14
|
||||
- Coverage: 22.2%
|
||||
|
||||
## Summary
|
||||
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
|
||||
- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`.
|
||||
- TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`.
|
||||
- Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-all’s recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows.
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients |
|
||||
| ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update |
|
||||
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
|
||||
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
|
||||
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
|
||||
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
|
||||
|
||||
124
tests/cli_e2e/mail/mail_draft_send_dryrun_test.go
Normal file
124
tests/cli_e2e/mail/mail_draft_send_dryrun_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestMail_DraftSendDryRun(t *testing.T) {
|
||||
setMailDraftSendDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--mailbox", "alias@example.com",
|
||||
"--draft-id", " draft_001, draft_002 ",
|
||||
"--draft-id", " draft_003 ",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
wantURLs := []string{
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send",
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send",
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send",
|
||||
}
|
||||
assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
|
||||
for i, wantURL := range wantURLs {
|
||||
idx := strconv.Itoa(i)
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMail_DraftSendDryRunValidation(t *testing.T) {
|
||||
setMailDraftSendDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "reject whitespace draft id",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", " ",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "reject too many draft ids",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", manyDraftIDsForE2E(51),
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "reject duplicate draft id",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", "draft_001,draft_002,draft_001",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "--draft-id contains duplicate value: draft_001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + result.Stderr
|
||||
assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setMailDraftSendDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func manyDraftIDsForE2E(n int) string {
|
||||
ids := make([]byte, 0, n*4)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
ids = append(ids, ',')
|
||||
}
|
||||
ids = append(ids, 'd')
|
||||
ids = strconv.AppendInt(ids, int64(i), 10)
|
||||
}
|
||||
return string(ids)
|
||||
}
|
||||
166
tests/cli_e2e/mail/mail_draft_send_workflow_test.go
Normal file
166
tests/cli_e2e/mail/mail_draft_send_workflow_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestMail_DraftSendWorkflowAsUser(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
const mailboxID = "me"
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
subject := "lark-cli-e2e-mail-draft-send-" + suffix
|
||||
body := "draft-send workflow body " + suffix
|
||||
|
||||
var primaryEmail string
|
||||
var draftID string
|
||||
var draftSent bool
|
||||
var sentMessageID string
|
||||
var inboxMessageID string
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
if draftID != "" && !draftSent {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailbox.drafts", "delete"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"user_mailbox_id": mailboxID,
|
||||
"draft_id": draftID,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
|
||||
}
|
||||
|
||||
var messageIDs []string
|
||||
if sentMessageID != "" {
|
||||
messageIDs = append(messageIDs, sentMessageID)
|
||||
}
|
||||
if inboxMessageID != "" && inboxMessageID != sentMessageID {
|
||||
messageIDs = append(messageIDs, inboxMessageID)
|
||||
}
|
||||
if len(messageIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"user_mailbox_id": mailboxID},
|
||||
Data: map[string]any{"message_ids": messageIDs},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err)
|
||||
})
|
||||
|
||||
t.Run("get mailbox profile as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailboxes", "profile"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"user_mailbox_id": mailboxID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
|
||||
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("create self-addressed draft as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-create",
|
||||
"--to", primaryEmail,
|
||||
"--subject", subject,
|
||||
"--body", body,
|
||||
"--plain-text",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
|
||||
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("send draft with shortcut as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, draftID, "draft should be created before +draft-send")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--mailbox", mailboxID,
|
||||
"--draft-id", draftID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String()
|
||||
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
|
||||
draftSent = true
|
||||
})
|
||||
|
||||
t.Run("find self-received message for cleanup", func(t *testing.T) {
|
||||
require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup")
|
||||
|
||||
for attempt := 0; attempt < 12; attempt++ {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+triage",
|
||||
"--mailbox", mailboxID,
|
||||
"--query", subject,
|
||||
"--max", "10",
|
||||
"--format", "data",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
for _, item := range gjson.Get(result.Stdout, "messages").Array() {
|
||||
if item.Get("subject").String() != subject {
|
||||
continue
|
||||
}
|
||||
messageID := item.Get("message_id").String()
|
||||
if messageID != "" && messageID != sentMessageID {
|
||||
inboxMessageID = messageID
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user