feat: backend service
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user