248 lines
7.1 KiB
Go
248 lines
7.1 KiB
Go
package mail_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
"galaxy/backend/internal/mail"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap/zaptest"
|
|
)
|
|
|
|
// recordingSender is a SMTPSender stub with programmable per-call
|
|
// behaviour. Tests append behaviours; each Send pops the head.
|
|
type recordingSender struct {
|
|
mu sync.Mutex
|
|
sent []mail.OutboundMessage
|
|
behaviour []func(mail.OutboundMessage) error
|
|
}
|
|
|
|
func newRecordingSender() *recordingSender { return &recordingSender{} }
|
|
|
|
func (r *recordingSender) Send(_ context.Context, msg mail.OutboundMessage) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.sent = append(r.sent, msg)
|
|
if len(r.behaviour) == 0 {
|
|
return nil
|
|
}
|
|
fn := r.behaviour[0]
|
|
r.behaviour = r.behaviour[1:]
|
|
return fn(msg)
|
|
}
|
|
|
|
func (r *recordingSender) snapshot() []mail.OutboundMessage {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]mail.OutboundMessage, len(r.sent))
|
|
copy(out, r.sent)
|
|
return out
|
|
}
|
|
|
|
// recordingAdminNotifier captures every dead-letter notification call.
|
|
type recordingAdminNotifier struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (r *recordingAdminNotifier) OnDeadLetter(_ context.Context, _ uuid.UUID, _, _ string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls++
|
|
}
|
|
|
|
func (r *recordingAdminNotifier) count() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.calls
|
|
}
|
|
|
|
// buildService spins up a Service backed by a real Postgres testcontainer.
|
|
// The fake clock and configurable max-attempts let tests exercise the
|
|
// retry / dead-letter branches without real time.
|
|
func buildService(t *testing.T, sender mail.SMTPSender, admin mail.AdminNotifier, maxAttempts int, now func() time.Time) *mail.Service {
|
|
t.Helper()
|
|
db := startPostgres(t)
|
|
svc := mail.NewService(mail.Deps{
|
|
Store: mail.NewStore(db),
|
|
SMTP: sender,
|
|
Admin: admin,
|
|
Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: maxAttempts},
|
|
Now: now,
|
|
Logger: zaptest.NewLogger(t),
|
|
})
|
|
return svc
|
|
}
|
|
|
|
func TestWorkerSuccessFirstAttempt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sender := newRecordingSender()
|
|
admin := &recordingAdminNotifier{}
|
|
svc := buildService(t, sender, admin, 3, time.Now)
|
|
|
|
if err := svc.EnqueueLoginCode(context.Background(), "alice@example.test", "111111", 5*time.Minute); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
worker := mail.NewWorker(svc)
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
|
|
sent := sender.snapshot()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("got %d sent messages, want 1", len(sent))
|
|
}
|
|
if sent[0].Subject == "" || len(sent[0].Body) == 0 {
|
|
t.Fatalf("sent message missing fields: %+v", sent[0])
|
|
}
|
|
|
|
page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(page.Items) != 1 {
|
|
t.Fatalf("want 1 delivery, got %d", len(page.Items))
|
|
}
|
|
if page.Items[0].Status != mail.StatusSent {
|
|
t.Fatalf("status=%q want sent", page.Items[0].Status)
|
|
}
|
|
if page.Items[0].Attempts != 1 {
|
|
t.Fatalf("attempts=%d want 1", page.Items[0].Attempts)
|
|
}
|
|
if admin.count() != 0 {
|
|
t.Fatalf("admin notifier must not fire on success, got %d", admin.count())
|
|
}
|
|
}
|
|
|
|
func TestWorkerTransientThenDeadLetter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sender := newRecordingSender()
|
|
sender.behaviour = []func(mail.OutboundMessage) error{
|
|
func(mail.OutboundMessage) error { return errors.New("smtp transient #1") },
|
|
func(mail.OutboundMessage) error { return errors.New("smtp transient #2") },
|
|
}
|
|
admin := &recordingAdminNotifier{}
|
|
|
|
// Start the fake clock 2 hours behind wall-clock so the
|
|
// `finishedAt + backoff` computed by ScheduleRetry lands in the
|
|
// past relative to DB `now()` and the second tick re-claims the
|
|
// row immediately.
|
|
clock := time.Now().UTC().Add(-2 * time.Hour)
|
|
svc := buildService(t, sender, admin, 2, func() time.Time { return clock })
|
|
|
|
if err := svc.EnqueueLoginCode(context.Background(), "bob@example.test", "222222", 5*time.Minute); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
worker := mail.NewWorker(svc)
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick #1: %v", err)
|
|
}
|
|
|
|
page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got := page.Items[0].Status; got != mail.StatusRetrying {
|
|
t.Fatalf("after first failure status=%q want retrying", got)
|
|
}
|
|
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick #2: %v", err)
|
|
}
|
|
|
|
page, err = svc.AdminListDeliveries(context.Background(), 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("list 2: %v", err)
|
|
}
|
|
if got := page.Items[0].Status; got != mail.StatusDeadLettered {
|
|
t.Fatalf("after second failure status=%q want dead_lettered", got)
|
|
}
|
|
if page.Items[0].Attempts != 2 {
|
|
t.Fatalf("attempts=%d want 2", page.Items[0].Attempts)
|
|
}
|
|
if admin.count() != 1 {
|
|
t.Fatalf("admin notifier calls=%d want 1", admin.count())
|
|
}
|
|
|
|
// Check dead-letter row exists.
|
|
dl, err := svc.AdminListDeadLetters(context.Background(), 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("list dead-letters: %v", err)
|
|
}
|
|
if dl.Total != 1 {
|
|
t.Fatalf("dead-letter total=%d want 1", dl.Total)
|
|
}
|
|
}
|
|
|
|
func TestWorkerPermanentDeadLettersImmediately(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sender := newRecordingSender()
|
|
sender.behaviour = []func(mail.OutboundMessage) error{
|
|
func(mail.OutboundMessage) error { return &mail.SendError{Err: errors.New("rejected"), Permanent: true} },
|
|
}
|
|
admin := &recordingAdminNotifier{}
|
|
svc := buildService(t, sender, admin, 5, time.Now)
|
|
|
|
if err := svc.EnqueueLoginCode(context.Background(), "e@example.test", "333333", 5*time.Minute); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
worker := mail.NewWorker(svc)
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick: %v", err)
|
|
}
|
|
|
|
page, err := svc.AdminListDeliveries(context.Background(), 1, 10)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if got := page.Items[0].Status; got != mail.StatusDeadLettered {
|
|
t.Fatalf("status=%q want dead_lettered after permanent error", got)
|
|
}
|
|
if admin.count() != 1 {
|
|
t.Fatalf("admin notifier calls=%d want 1", admin.count())
|
|
}
|
|
}
|
|
|
|
func TestWorkerRespectsNextAttemptAt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sender := newRecordingSender()
|
|
sender.behaviour = []func(mail.OutboundMessage) error{
|
|
func(mail.OutboundMessage) error { return errors.New("transient") },
|
|
}
|
|
// Push the fake clock far into the future so the post-retry
|
|
// next_attempt_at lands well past wall-clock now() and the second
|
|
// tick deterministically skips the row.
|
|
clock := time.Now().UTC().Add(24 * time.Hour)
|
|
admin := &recordingAdminNotifier{}
|
|
svc := buildService(t, sender, admin, 5, func() time.Time { return clock })
|
|
|
|
if err := svc.EnqueueLoginCode(context.Background(), "f@example.test", "444444", 5*time.Minute); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
worker := mail.NewWorker(svc)
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick #1: %v", err)
|
|
}
|
|
|
|
// Without advancing the clock the next tick must skip the row
|
|
// because next_attempt_at > now().
|
|
if err := worker.Tick(context.Background()); err != nil {
|
|
t.Fatalf("tick #2: %v", err)
|
|
}
|
|
if got := len(sender.snapshot()); got != 1 {
|
|
t.Fatalf("sender saw %d messages while still backing off, want 1", got)
|
|
}
|
|
}
|