Files
2026-05-06 10:14:55 +03:00

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)
}
}