package mail_test import ( "context" "errors" "testing" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/mail" "github.com/google/uuid" "go.uber.org/zap/zaptest" ) func TestAdminListPagination(t *testing.T) { t.Parallel() db := startPostgres(t) svc := mail.NewService(mail.Deps{ Store: mail.NewStore(db), SMTP: newRecordingSender(), Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: 3}, Logger: zaptest.NewLogger(t), }) const total = 7 for i := range total { if err := svc.EnqueueLoginCode(context.Background(), "a@example.test", "1234"+string(rune('0'+i)), 5*time.Minute); err != nil { t.Fatalf("enqueue %d: %v", i, err) } } page, err := svc.AdminListDeliveries(context.Background(), 1, 3) if err != nil { t.Fatalf("list page 1: %v", err) } if len(page.Items) != 3 { t.Fatalf("page1 size=%d want 3", len(page.Items)) } if page.Total != total { t.Fatalf("page1 total=%d want %d", page.Total, total) } page, err = svc.AdminListDeliveries(context.Background(), 3, 3) if err != nil { t.Fatalf("list page 3: %v", err) } if len(page.Items) != 1 { t.Fatalf("page3 size=%d want 1", len(page.Items)) } } func TestAdminGetDeliveryNotFound(t *testing.T) { t.Parallel() db := startPostgres(t) svc := mail.NewService(mail.Deps{ Store: mail.NewStore(db), SMTP: newRecordingSender(), Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: 3}, Logger: zaptest.NewLogger(t), }) if _, err := svc.AdminGetDelivery(context.Background(), uuid.New()); !errors.Is(err, mail.ErrDeliveryNotFound) { t.Fatalf("get missing: want ErrDeliveryNotFound, got %v", err) } } func TestAdminResendStateMatrix(t *testing.T) { t.Parallel() db := startPostgres(t) sender := newRecordingSender() // Match the number of Send calls the matrix triggers (initial // success path + resend re-send for the dead-lettered row). sender.behaviour = []func(mail.OutboundMessage) error{ func(mail.OutboundMessage) error { return errors.New("transient #1") }, func(mail.OutboundMessage) error { return errors.New("transient #2") }, func(mail.OutboundMessage) error { return nil }, // sent path } clock := time.Now().UTC().Add(-2 * time.Hour) // bring next_attempt_at into the past svc := mail.NewService(mail.Deps{ Store: mail.NewStore(db), SMTP: sender, Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: 2}, Now: func() time.Time { return clock }, Logger: zaptest.NewLogger(t), }) worker := mail.NewWorker(svc) // 1. Drive a row to dead-lettered (two failures with MaxAttempts=2). if err := svc.EnqueueLoginCode(context.Background(), "dead@example.test", "111111", 5*time.Minute); err != nil { t.Fatalf("enqueue dead: %v", err) } if err := worker.Tick(context.Background()); err != nil { t.Fatalf("tick #1: %v", err) } if err := worker.Tick(context.Background()); err != nil { t.Fatalf("tick #2: %v", err) } deadList, err := svc.AdminListDeliveries(context.Background(), 1, 5) if err != nil { t.Fatalf("list: %v", err) } if len(deadList.Items) != 1 || deadList.Items[0].Status != mail.StatusDeadLettered { t.Fatalf("want 1 dead-lettered row, got %+v", deadList.Items) } deadID := deadList.Items[0].DeliveryID // 2. Resend the dead-lettered row -> 200, status flips to pending, // attempts=0. resent, err := svc.AdminResendDelivery(context.Background(), deadID) if err != nil { t.Fatalf("resend dead: %v", err) } if resent.Status != mail.StatusPending { t.Fatalf("status after resend=%q want pending", resent.Status) } if resent.Attempts != 0 { t.Fatalf("attempts after resend=%d want 0", resent.Attempts) } // 3. Drive the worker once more — third Send call returns nil so // the row transitions to sent. if err := worker.Tick(context.Background()); err != nil { t.Fatalf("tick post-resend: %v", err) } d, err := svc.AdminGetDelivery(context.Background(), deadID) if err != nil { t.Fatalf("get after send: %v", err) } if d.Status != mail.StatusSent { t.Fatalf("status=%q want sent", d.Status) } // 4. Resend on `sent` -> ErrResendOnSent. if _, err := svc.AdminResendDelivery(context.Background(), deadID); !errors.Is(err, mail.ErrResendOnSent) { t.Fatalf("resend on sent: want ErrResendOnSent, got %v", err) } // 5. Resend on missing -> ErrDeliveryNotFound. if _, err := svc.AdminResendDelivery(context.Background(), uuid.New()); !errors.Is(err, mail.ErrDeliveryNotFound) { t.Fatalf("resend missing: want ErrDeliveryNotFound, got %v", err) } } func TestServiceStats(t *testing.T) { t.Parallel() db := startPostgres(t) svc := mail.NewService(mail.Deps{ Store: mail.NewStore(db), SMTP: newRecordingSender(), Config: config.MailConfig{WorkerInterval: time.Hour, MaxAttempts: 3}, Logger: zaptest.NewLogger(t), }) for i := range 3 { if err := svc.EnqueueLoginCode(context.Background(), "stats@example.test", "55555"+string(rune('0'+i)), 5*time.Minute); err != nil { t.Fatalf("enqueue: %v", err) } } stats, err := svc.Stats(context.Background()) if err != nil { t.Fatalf("stats: %v", err) } if stats[mail.StatusPending] != 3 { t.Fatalf("pending=%d want 3", stats[mail.StatusPending]) } if _, ok := stats[mail.StatusSent]; !ok { t.Fatal("Stats must always return all four buckets") } }