mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* feat(mail): return typed error envelopes across the mail domain Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text. - Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable. - API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved. - Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal. - Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls. - Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors. * fix(mail): report aborted draft-send batches as a single failure result When an account-level failure interrupts a batch send after some drafts already went out, the command previously produced two machine-readable failure results: the partial-failure ledger on stdout and a second error envelope on stderr. Consumers could not tell which one to recover from. The batch ledger is now the only failure result for that case: it gains aborted and abort_error fields carrying the typed cause, so callers can see which drafts were sent, which failed, why the batch stopped, and how to recover — all from stdout. A --stop-on-error stop keeps these fields unset because stopping early there is the caller's own choice.
308 lines
8.7 KiB
Go
308 lines
8.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
)
|
|
|
|
func TestMailFileIOErrorTyped(t *testing.T) {
|
|
cause := errors.New("disk read failed")
|
|
|
|
err := mailFileIOError("load %s: %v", cause, "body.html", cause)
|
|
|
|
var internalErr *errs.InternalError
|
|
if !errors.As(err, &internalErr) {
|
|
t.Fatalf("expected internal error, got %T", err)
|
|
}
|
|
if !errors.Is(err, cause) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Subtype != errs.SubtypeFileIO {
|
|
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFileIO)
|
|
}
|
|
if !strings.Contains(p.Message, "body.html") || !strings.Contains(p.Message, "disk read failed") {
|
|
t.Fatalf("message missing context: %q", p.Message)
|
|
}
|
|
}
|
|
|
|
func TestMailFileIOErrorDoesNotAppendCauseAsFormatArg(t *testing.T) {
|
|
cause := errors.New("mkdir denied")
|
|
|
|
err := mailFileIOError("cannot create output directory %q", cause, "out")
|
|
|
|
if !errors.Is(err, cause) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if strings.Contains(p.Message, "%!(") {
|
|
t.Fatalf("message contains fmt extra marker: %q", p.Message)
|
|
}
|
|
if strings.Contains(p.Message, "mkdir denied") {
|
|
t.Fatalf("cause should not be implicitly appended to message: %q", p.Message)
|
|
}
|
|
}
|
|
|
|
func TestMailInputStatErrorTyped(t *testing.T) {
|
|
if err := mailInputStatError(nil); err != nil {
|
|
t.Fatalf("nil input should stay nil, got %v", err)
|
|
}
|
|
|
|
pathErr := fileio.ErrPathValidation
|
|
err := mailInputStatError(pathErr)
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected validation error, got %T", err)
|
|
}
|
|
if !errors.Is(err, pathErr) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "unsafe file path") {
|
|
t.Fatalf("unexpected path validation message: %v", err)
|
|
}
|
|
|
|
statErr := errors.New("permission denied")
|
|
err = mailInputStatError(statErr)
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected validation error, got %T", err)
|
|
}
|
|
if !errors.Is(err, statErr) {
|
|
t.Fatalf("stat cause not preserved: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot read file") {
|
|
t.Fatalf("unexpected stat message: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMailDecorateProblemMessageTypedAndPlain(t *testing.T) {
|
|
if err := mailDecorateProblemMessage(nil, "fetch profile"); err != nil {
|
|
t.Fatalf("nil input should stay nil, got %v", err)
|
|
}
|
|
|
|
typedErr := errs.NewAPIError(errs.SubtypeRateLimit, "too many requests")
|
|
err := mailDecorateProblemMessage(typedErr, "fetch %s", "profile")
|
|
if err != typedErr {
|
|
t.Fatalf("typed error should be decorated in place")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Message != "fetch profile: too many requests" {
|
|
t.Fatalf("message = %q", p.Message)
|
|
}
|
|
|
|
blankPrefixErr := errs.NewAPIError(errs.SubtypeUnknown, "unchanged")
|
|
err = mailDecorateProblemMessage(blankPrefixErr, " ")
|
|
p, ok = errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Message != "unchanged" {
|
|
t.Fatalf("blank prefix should not change message, got %q", p.Message)
|
|
}
|
|
|
|
plainCause := errors.New("sdk failed")
|
|
err = mailDecorateProblemMessage(plainCause, "fetch mailbox")
|
|
var internalErr *errs.InternalError
|
|
if !errors.As(err, &internalErr) {
|
|
t.Fatalf("plain error should be upgraded to internal SDK error, got %T", err)
|
|
}
|
|
if !errors.Is(err, plainCause) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
p, ok = errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Subtype != errs.SubtypeSDKError || !strings.Contains(p.Message, "fetch mailbox: sdk failed") {
|
|
t.Fatalf("unexpected problem: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestMailAppendProblemHintTypedAndPlain(t *testing.T) {
|
|
if err := mailAppendProblemHint(nil, "retry later"); err != nil {
|
|
t.Fatalf("nil input should stay nil, got %v", err)
|
|
}
|
|
|
|
withoutHint := errs.NewAPIError(errs.SubtypeUnknown, "failed")
|
|
err := mailAppendProblemHint(withoutHint, "retry later")
|
|
if err != withoutHint {
|
|
t.Fatalf("typed error should be updated in place")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Hint != "retry later" {
|
|
t.Fatalf("hint = %q", p.Hint)
|
|
}
|
|
|
|
withHint := errs.NewAPIError(errs.SubtypeUnknown, "failed").WithHint("check scope")
|
|
err = mailAppendProblemHint(withHint, "retry later")
|
|
p, ok = errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Hint != "check scope; retry later" {
|
|
t.Fatalf("hint = %q", p.Hint)
|
|
}
|
|
|
|
plainCause := errors.New("legacy api failed")
|
|
err = mailAppendProblemHint(plainCause, "retry later")
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("plain error should be upgraded to API error, got %T", err)
|
|
}
|
|
if !errors.Is(err, plainCause) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
p, ok = errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T", err)
|
|
}
|
|
if p.Hint != "retry later" || p.Subtype != errs.SubtypeUnknown {
|
|
t.Fatalf("unexpected problem: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestValidateBodyFileMutexTypedErrors(t *testing.T) {
|
|
err := validateBodyFileMutex("<p>Hello</p>", "body.html", func(string) error { return nil })
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected validation error, got %T", err)
|
|
}
|
|
if len(validationErr.Params) != 2 {
|
|
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
|
|
}
|
|
if validationErr.Params[0].Name != "--body" || validationErr.Params[1].Name != "--body-file" {
|
|
t.Fatalf("unexpected params: %#v", validationErr.Params)
|
|
}
|
|
|
|
pathErr := errors.New("outside cwd")
|
|
err = validateBodyFileMutex("", "body.html", func(string) error { return pathErr })
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected validation error, got %T", err)
|
|
}
|
|
if validationErr.Param != "--body-file" {
|
|
t.Fatalf("param = %q, want --body-file", validationErr.Param)
|
|
}
|
|
if !errors.Is(err, pathErr) {
|
|
t.Fatalf("cause not preserved: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadBodyFileTypedErrors(t *testing.T) {
|
|
openErr := errors.New("missing")
|
|
_, err := readBodyFile(bodyFileTestIO{
|
|
open: func(string) (fileio.File, error) { return nil, openErr },
|
|
}, "missing.html")
|
|
requireBodyFileValidationError(t, err, openErr)
|
|
if !strings.Contains(err.Error(), "open --body-file missing.html") {
|
|
t.Fatalf("unexpected open message: %v", err)
|
|
}
|
|
|
|
readErr := errors.New("read broken")
|
|
_, err = readBodyFile(bodyFileTestIO{
|
|
open: func(string) (fileio.File, error) {
|
|
return &bodyFileTestFile{readErr: readErr}, nil
|
|
},
|
|
}, "body.html")
|
|
requireBodyFileValidationError(t, err, readErr)
|
|
if !strings.Contains(err.Error(), "read --body-file body.html") {
|
|
t.Fatalf("unexpected read message: %v", err)
|
|
}
|
|
|
|
_, err = readBodyFile(bodyFileTestIO{
|
|
open: func(string) (fileio.File, error) {
|
|
return &bodyFileTestFile{remaining: maxBodyFileSize + 1}, nil
|
|
},
|
|
}, "huge.html")
|
|
requireBodyFileValidationError(t, err, nil)
|
|
if !strings.Contains(err.Error(), "file exceeds 32 MB limit") {
|
|
t.Fatalf("unexpected size message: %v", err)
|
|
}
|
|
}
|
|
|
|
func requireBodyFileValidationError(t *testing.T, err error, cause error) {
|
|
t.Helper()
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected validation error, got %T (%v)", err, err)
|
|
}
|
|
if validationErr.Param != "--body-file" {
|
|
t.Fatalf("param = %q, want --body-file", validationErr.Param)
|
|
}
|
|
if cause != nil && !errors.Is(err, cause) {
|
|
t.Fatalf("cause %v not preserved in %v", cause, err)
|
|
}
|
|
}
|
|
|
|
type bodyFileTestIO struct {
|
|
open func(string) (fileio.File, error)
|
|
}
|
|
|
|
func (fio bodyFileTestIO) Open(name string) (fileio.File, error) {
|
|
return fio.open(name)
|
|
}
|
|
|
|
func (fio bodyFileTestIO) Stat(string) (fileio.FileInfo, error) {
|
|
return nil, errors.New("unused")
|
|
}
|
|
|
|
func (fio bodyFileTestIO) ResolvePath(path string) (string, error) {
|
|
return path, nil
|
|
}
|
|
|
|
func (fio bodyFileTestIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
|
return nil, errors.New("unused")
|
|
}
|
|
|
|
type bodyFileTestFile struct {
|
|
readErr error
|
|
remaining int
|
|
}
|
|
|
|
func (f *bodyFileTestFile) Read(p []byte) (int, error) {
|
|
if f.readErr != nil {
|
|
return 0, f.readErr
|
|
}
|
|
if f.remaining <= 0 {
|
|
return 0, io.EOF
|
|
}
|
|
n := len(p)
|
|
if n > f.remaining {
|
|
n = f.remaining
|
|
}
|
|
for i := range p[:n] {
|
|
p[i] = 'x'
|
|
}
|
|
f.remaining -= n
|
|
return n, nil
|
|
}
|
|
|
|
func (f *bodyFileTestFile) ReadAt([]byte, int64) (int, error) {
|
|
return 0, errors.New("unused")
|
|
}
|
|
|
|
func (f *bodyFileTestFile) Close() error {
|
|
return nil
|
|
}
|
|
|
|
var _ fileio.FileIO = bodyFileTestIO{}
|
|
var _ fileio.File = (*bodyFileTestFile)(nil)
|