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