package worker import ( "context" "errors" "testing" "time" "wucher/internal/config" "wucher/internal/domain/auth" "wucher/internal/queue" ) type stubOutboxRepo struct { messages map[string]*auth.EmailOutboxMessage } func newStubOutboxRepo(messages ...*auth.EmailOutboxMessage) *stubOutboxRepo { repo := &stubOutboxRepo{messages: map[string]*auth.EmailOutboxMessage{}} for _, message := range messages { if message == nil { continue } cp := *message cp.ID = append([]byte(nil), message.ID...) cp.Body = append([]byte(nil), message.Body...) cp.Attributes = append([]byte(nil), message.Attributes...) repo.messages[string(cp.ID)] = &cp } return repo } func (r *stubOutboxRepo) CreateEmailOutboxMessage(_ context.Context, message *auth.EmailOutboxMessage) error { if message == nil { return errors.New("nil message") } cp := *message cp.ID = append([]byte(nil), message.ID...) cp.Body = append([]byte(nil), message.Body...) cp.Attributes = append([]byte(nil), message.Attributes...) r.messages[string(cp.ID)] = &cp return nil } func (r *stubOutboxRepo) ClaimPendingEmailOutboxMessages(_ context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]auth.EmailOutboxMessage, error) { if limit <= 0 { limit = 1 } if lockTTL <= 0 { lockTTL = time.Minute } out := make([]auth.EmailOutboxMessage, 0, limit) staleBefore := now.Add(-lockTTL) for _, message := range r.messages { if len(out) >= limit { break } switch { case message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now): case message.Status == auth.EmailOutboxStatusProcessing && message.LockedAt != nil && message.LockedAt.Before(staleBefore): default: continue } message.Status = auth.EmailOutboxStatusProcessing message.Attempts++ ts := now message.LockedAt = &ts message.LockedBy = workerID cp := *message out = append(out, cp) } return out, nil } func (r *stubOutboxRepo) MarkEmailOutboxMessagePublished(_ context.Context, id []byte, publishedAt time.Time) error { message := r.messages[string(id)] if message == nil { return errors.New("not found") } message.Status = auth.EmailOutboxStatusPublished message.PublishedAt = &publishedAt message.LockedAt = nil message.LockedBy = "" message.LastError = "" return nil } func (r *stubOutboxRepo) MarkEmailOutboxMessageRetry(_ context.Context, id []byte, availableAt time.Time, lastErr string) error { message := r.messages[string(id)] if message == nil { return errors.New("not found") } message.Status = auth.EmailOutboxStatusPending message.AvailableAt = availableAt message.LockedAt = nil message.LockedBy = "" message.LastError = lastErr return nil } func (r *stubOutboxRepo) MarkEmailOutboxMessageDead(_ context.Context, id []byte, failedAt time.Time, lastErr string) error { message := r.messages[string(id)] if message == nil { return errors.New("not found") } message.Status = auth.EmailOutboxStatusDead message.AvailableAt = failedAt message.LockedAt = nil message.LockedBy = "" message.LastError = lastErr return nil } func (r *stubOutboxRepo) CountPendingEmailOutboxMessages(_ context.Context, now time.Time) (int64, error) { var count int64 for _, message := range r.messages { if (message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now)) || message.Status == auth.EmailOutboxStatusProcessing { count++ } } return count, nil } type stubQueueProducer struct { published []queue.OutboundMessage err error } func (p *stubQueueProducer) Backend() string { return "stub" } func (p *stubQueueProducer) Publish(_ context.Context, message queue.OutboundMessage) error { if p.err != nil { return p.err } p.published = append(p.published, message) return nil } func newOutboxRecord(t *testing.T, job queue.EmailJob) *auth.EmailOutboxMessage { t.Helper() serializer := queue.NewJSONSerializer("v1", true, queue.QueueTypeStandard, "") message, err := serializer.Serialize(context.Background(), job) if err != nil { t.Fatalf("serialize: %v", err) } record, err := auth.NewEmailOutboxMessage(time.Now().UTC(), message) if err != nil { t.Fatalf("new outbox message: %v", err) } return record } func TestOutboxDispatcherPublishSuccess(t *testing.T) { record := newOutboxRecord(t, queue.EmailJob{ Type: "email_verify", To: "user@example.com", Subject: "Verify", Body: "hello", }) repo := newStubOutboxRepo(record) producer := &stubQueueProducer{} dispatcher := NewOutboxDispatcher(config.QueueConfig{ OutboxEnabled: true, OutboxBatchSize: 10, OutboxDispatchTimeout: time.Second, OutboxLockTTL: time.Minute, OutboxMaxAttempts: 3, WorkerBackoffMin: time.Second, WorkerBackoffMax: time.Minute, }, repo, producer, nil) processed, err := dispatcher.dispatchOnce(context.Background()) if err != nil { t.Fatalf("dispatch once: %v", err) } if processed != 1 { t.Fatalf("expected one processed message, got %d", processed) } if len(producer.published) != 1 { t.Fatalf("expected one published message, got %d", len(producer.published)) } if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished { t.Fatalf("expected message published, got %s", repo.messages[string(record.ID)].Status) } if got := dispatcher.Metrics().Published; got != 1 { t.Fatalf("expected published metric=1, got %d", got) } } func TestOutboxDispatcherSkipRegisterVerifyEmail(t *testing.T) { record := newOutboxRecord(t, queue.EmailJob{ MessageType: "email_verify", To: "user@example.com", Subject: "Verify", TextBody: "hello", Metadata: map[string]string{ "skip_send": "true", }, Tags: map[string]string{ "source": "register", }, }) repo := newStubOutboxRepo(record) producer := &stubQueueProducer{} dispatcher := NewOutboxDispatcher(config.QueueConfig{ OutboxEnabled: true, OutboxBatchSize: 10, OutboxDispatchTimeout: time.Second, OutboxLockTTL: time.Minute, OutboxMaxAttempts: 3, WorkerBackoffMin: time.Second, WorkerBackoffMax: time.Minute, }, repo, producer, nil) processed, err := dispatcher.dispatchOnce(context.Background()) if err != nil { t.Fatalf("dispatch once: %v", err) } if processed != 1 { t.Fatalf("expected one processed message, got %d", processed) } if len(producer.published) != 0 { t.Fatalf("expected zero published message, got %d", len(producer.published)) } if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished { t.Fatalf("expected message published status after skip, got %s", repo.messages[string(record.ID)].Status) } } func TestOutboxDispatcherRetryOnPublishFailure(t *testing.T) { record := newOutboxRecord(t, queue.EmailJob{ Type: "password_reset", To: "user@example.com", Subject: "Reset", Body: "reset", }) repo := newStubOutboxRepo(record) producer := &stubQueueProducer{err: errors.New("queue unavailable")} dispatcher := NewOutboxDispatcher(config.QueueConfig{ OutboxEnabled: true, OutboxBatchSize: 10, OutboxLockTTL: time.Minute, OutboxMaxAttempts: 3, WorkerBackoffMin: time.Second, WorkerBackoffMax: time.Minute, }, repo, producer, nil) processed, err := dispatcher.dispatchOnce(context.Background()) if err != nil { t.Fatalf("dispatch once: %v", err) } if processed != 1 { t.Fatalf("expected one processed message, got %d", processed) } got := repo.messages[string(record.ID)] if got.Status != auth.EmailOutboxStatusPending { t.Fatalf("expected message returned to pending, got %s", got.Status) } if got.Attempts != 1 { t.Fatalf("expected attempts=1, got %d", got.Attempts) } if !got.AvailableAt.After(time.Now().Add(-time.Second)) { t.Fatalf("expected retry to schedule next attempt in the future") } if metrics := dispatcher.Metrics(); metrics.Retried != 1 { t.Fatalf("expected retried metric=1, got %d", metrics.Retried) } } func TestOutboxDispatcherMarksDeadAfterMaxAttempts(t *testing.T) { record := newOutboxRecord(t, queue.EmailJob{ Type: "invite_set_password", To: "user@example.com", Subject: "Invite", Body: "invite", }) record.Attempts = 2 repo := newStubOutboxRepo(record) producer := &stubQueueProducer{err: errors.New("queue unavailable")} dispatcher := NewOutboxDispatcher(config.QueueConfig{ OutboxEnabled: true, OutboxBatchSize: 10, OutboxLockTTL: time.Minute, OutboxMaxAttempts: 3, WorkerBackoffMin: time.Second, WorkerBackoffMax: time.Minute, }, repo, producer, nil) processed, err := dispatcher.dispatchOnce(context.Background()) if err != nil { t.Fatalf("dispatch once: %v", err) } if processed != 1 { t.Fatalf("expected one processed message, got %d", processed) } if got := repo.messages[string(record.ID)].Status; got != auth.EmailOutboxStatusDead { t.Fatalf("expected dead status, got %s", got) } if got := dispatcher.Metrics().Dead; got != 1 { t.Fatalf("expected dead metric=1, got %d", got) } }