Files
fm_be/internal/service/ses_sender_test.go
2026-07-16 22:16:45 +07:00

241 lines
7.5 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"github.com/aws/smithy-go"
smithymiddleware "github.com/aws/smithy-go/middleware"
"wucher/internal/config"
"wucher/internal/queue"
)
type mockSESAPI struct {
input *sesv2.SendEmailInput
output *sesv2.SendEmailOutput
err error
calls int
}
func (m *mockSESAPI) SendEmail(_ context.Context, params *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
m.calls++
m.input = params
if m.err != nil {
return nil, m.err
}
if m.output != nil {
return m.output, nil
}
return &sesv2.SendEmailOutput{}, nil
}
func TestSESSenderSendSimple(t *testing.T) {
metadata := smithymiddleware.Metadata{}
awsmiddleware.SetRequestIDMetadata(&metadata, "req-123")
api := &mockSESAPI{
output: &sesv2.SendEmailOutput{
MessageId: strPtr("ses-msg-1"),
ResultMetadata: metadata,
},
}
sender := NewSESSenderWithAPI(config.SESConfig{
Region: "ap-southeast-1",
From: "Wucher <no-reply@example.com>",
ConfigurationSetName: "auth-events",
}, api)
result, err := sender.Send(context.Background(), queue.EmailJob{
MessageType: "password_reset",
To: "user@example.com",
CC: []string{"cc@example.com"},
BCC: []string{"bcc@example.com"},
ReplyTo: []string{"reply@example.com"},
Subject: "Reset Password",
TextBody: "plain body",
HTMLBody: "<p>html body</p>",
Tags: map[string]string{
"message_type": "password_reset",
"template": "auth_password_reset",
},
})
if err != nil {
t.Fatalf("send: %v", err)
}
if result.Provider != "ses" || result.ProviderMessageID != "ses-msg-1" || result.RequestID != "req-123" {
t.Fatalf("unexpected result: %+v", result)
}
if api.calls != 1 || api.input == nil {
t.Fatalf("expected SES SendEmail to be called once")
}
if got := *api.input.ConfigurationSetName; got != "auth-events" {
t.Fatalf("unexpected configuration set: %q", got)
}
if got := *api.input.FromEmailAddress; got != "\"Wucher\" <no-reply@example.com>" {
t.Fatalf("unexpected from address: %q", got)
}
if got := api.input.Destination.ToAddresses; len(got) != 1 || got[0] != "user@example.com" {
t.Fatalf("unexpected to addresses: %#v", got)
}
if got := api.input.Destination.CcAddresses; len(got) != 1 || got[0] != "cc@example.com" {
t.Fatalf("unexpected cc addresses: %#v", got)
}
if got := api.input.Destination.BccAddresses; len(got) != 1 || got[0] != "bcc@example.com" {
t.Fatalf("unexpected bcc addresses: %#v", got)
}
if got := api.input.ReplyToAddresses; len(got) != 1 || got[0] != "reply@example.com" {
t.Fatalf("unexpected reply-to addresses: %#v", got)
}
if api.input.Content == nil || api.input.Content.Simple == nil {
t.Fatalf("expected simple SES content")
}
if got := *api.input.Content.Simple.Subject.Data; got != "Reset Password" {
t.Fatalf("unexpected subject: %q", got)
}
if got := *api.input.Content.Simple.Body.Text.Data; got != "plain body" {
t.Fatalf("unexpected text body: %q", got)
}
if got := *api.input.Content.Simple.Body.Html.Data; got != "<p>html body</p>" {
t.Fatalf("unexpected html body: %q", got)
}
if len(api.input.EmailTags) != 2 {
t.Fatalf("expected tags to be forwarded, got %#v", api.input.EmailTags)
}
}
func TestSESSenderSendTemplate(t *testing.T) {
api := &mockSESAPI{}
sender := NewSESSenderWithAPI(config.SESConfig{
Region: "ap-southeast-1",
From: "no-reply@example.com",
}, api)
_, err := sender.Send(context.Background(), queue.EmailJob{
MessageType: "invite_set_password",
To: "user@example.com",
TemplateName: "auth.invite_set_password",
TemplateData: map[string]any{"name": "John"},
})
if err != nil {
t.Fatalf("send: %v", err)
}
if api.input == nil || api.input.Content == nil || api.input.Content.Template == nil {
t.Fatalf("expected templated SES content")
}
if got := *api.input.Content.Template.TemplateName; got != "auth.invite_set_password" {
t.Fatalf("unexpected template name: %q", got)
}
if got := *api.input.Content.Template.TemplateData; got != "{\"name\":\"John\"}" {
t.Fatalf("unexpected template data: %q", got)
}
}
func TestSESSenderSendInlineAttachmentUsesRawMIME(t *testing.T) {
api := &mockSESAPI{}
sender := NewSESSenderWithAPI(config.SESConfig{
Region: "ap-southeast-1",
From: "no-reply@example.com",
}, api)
_, err := sender.Send(context.Background(), queue.EmailJob{
MessageType: "security_pin_reset",
To: "user@example.com",
Subject: "Reset Security PIN",
TextBody: "plain body",
HTMLBody: `<img src="cid:branding-email-logo"><p>html body</p>`,
Attachments: []queue.EmailAttachment{{
Filename: "email-logo.png",
ContentType: "image/png",
ContentID: "branding-email-logo",
Content: []byte("png"),
Inline: true,
}},
})
if err != nil {
t.Fatalf("send: %v", err)
}
if api.input == nil || api.input.Content == nil || api.input.Content.Raw == nil {
t.Fatalf("expected raw SES content")
}
raw := string(api.input.Content.Raw.Data)
if !strings.Contains(raw, `Content-ID: <branding-email-logo>`) {
t.Fatalf("expected inline content id in raw MIME: %q", raw)
}
if !strings.Contains(raw, `Content-Disposition: inline; filename="email-logo.png"`) {
t.Fatalf("expected inline content disposition in raw MIME: %q", raw)
}
if !strings.Contains(raw, `cid:branding-email-logo`) {
t.Fatalf("expected html body cid reference in raw MIME: %q", raw)
}
}
func TestSESSenderSendValidationError(t *testing.T) {
sender := NewSESSenderWithAPI(config.SESConfig{
Region: "ap-southeast-1",
From: "no-reply@example.com",
}, &mockSESAPI{})
if _, err := sender.Send(context.Background(), queue.EmailJob{
To: "not-an-email",
Subject: "Hello",
TextBody: "Body",
}); err == nil {
t.Fatalf("expected invalid recipient error")
}
invalidCfgSender := NewSESSenderWithAPI(config.SESConfig{}, &mockSESAPI{})
if _, err := invalidCfgSender.Send(context.Background(), queue.EmailJob{
To: "user@example.com",
Subject: "Hello",
TextBody: "Body",
}); err == nil {
t.Fatalf("expected missing SES config error")
}
}
func TestClassifyDeliveryError(t *testing.T) {
t.Run("throttling is transient", func(t *testing.T) {
err := classifyDeliveryError(sesSenderAPIError{code: "TooManyRequestsException", fault: smithy.FaultClient})
if !queue.IsTransient(err) {
t.Fatalf("expected transient classification, got %v", err)
}
})
t.Run("message rejected is permanent", func(t *testing.T) {
err := classifyDeliveryError(sesSenderAPIError{code: "MessageRejected", fault: smithy.FaultClient})
if !queue.IsPermanent(err) {
t.Fatalf("expected permanent classification, got %v", err)
}
})
t.Run("validation error is permanent", func(t *testing.T) {
err := classifyDeliveryError(errors.New("invalid email job: recipient is required"))
if !queue.IsPermanent(err) {
t.Fatalf("expected permanent classification, got %v", err)
}
})
}
type sesSenderAPIError struct {
code string
fault smithy.ErrorFault
}
func (e sesSenderAPIError) Error() string { return e.code }
func (e sesSenderAPIError) ErrorCode() string { return e.code }
func (e sesSenderAPIError) ErrorMessage() string { return e.code }
func (e sesSenderAPIError) ErrorFault() smithy.ErrorFault { return e.fault }
func strPtr(value string) *string {
return &value
}
var _ sesAPI = (*mockSESAPI)(nil)
var _ = sestypes.MessageTag{}