//go:build integration package jobs_test import ( "context" "encoding/json" "errors" "sync/atomic" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/jobs" ) type recordingHandler struct { typ jobs.JobType calls atomic.Int32 wantErr error beforeRet func() } func (h *recordingHandler) Type() jobs.JobType { return h.typ } func (h *recordingHandler) Handle(ctx context.Context, job jobs.Job) error { h.calls.Add(1) if h.beforeRet != nil { h.beforeRet() } return h.wantErr } func TestWorker_HandlesAndCompletes(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) _, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver} w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ ID: "test#1", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{30 * time.Second}, Clock: clock.Real(), Log: discardLogger(), }) cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { return h.calls.Load() >= 1 }, 200*time.Millisecond, 5*time.Millisecond) cancel() w.Wait() } func TestWorker_RetryableErrorReschedules(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: errors.New("transient")} w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ ID: "t#1", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{30 * time.Second, 2 * time.Minute}, Clock: clock.Real(), Log: discardLogger(), }) cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { return h.calls.Load() >= 1 }, 200*time.Millisecond, 5*time.Millisecond) cancel() w.Wait() got, err := repo.Get(ctx, enqueued.ID) require.NoError(t, err) assert.Equal(t, jobs.StatusPending, got.Status) assert.Equal(t, int32(1), got.Attempts) require.NotNil(t, got.LastError) assert.Equal(t, "transient", *got.LastError) } func TestWorker_PermanentErrorDeadLetters(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: jobs.Permanent(errors.New("4xx config error"))} w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()}) cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { got, _ := repo.Get(ctx, enqueued.ID) return got != nil && got.Status == jobs.StatusDead }, 300*time.Millisecond, 10*time.Millisecond) cancel() w.Wait() } func TestWorker_MaxAttemptsExhaustedDeadLetters(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 2) // max=2 require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: errors.New("transient")} w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ID: "t", PollInterval: 2 * time.Millisecond, Backoff: []time.Duration{time.Millisecond}, Clock: clock.Real(), Log: discardLogger()}) cctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { got, _ := repo.Get(ctx, enqueued.ID) return got != nil && got.Status == jobs.StatusDead }, 500*time.Millisecond, 10*time.Millisecond) cancel() w.Wait() } func TestWorker_HandlerPanicTreatedAsRetry(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver, beforeRet: func() { panic("kaboom") }} w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()}) cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { got, _ := repo.Get(ctx, enqueued.ID) return got != nil && got.Status == jobs.StatusPending && got.Attempts >= 1 }, 300*time.Millisecond, 10*time.Millisecond) cancel() w.Wait() } func TestWorker_UnknownTypeDeadLetters(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, "unknown.type", json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{}, jobs.WorkerOptions{ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger()}) cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { got, _ := repo.Get(ctx, enqueued.ID) return got != nil && got.Status == jobs.StatusDead }, 200*time.Millisecond, 10*time.Millisecond) cancel() w.Wait() } func TestWorker_DeadLetterHookCalled(t *testing.T) { t.Parallel() ctx := context.Background() repo, _ := setupRepo(t) enqueued, err := repo.Enqueue(ctx, jobs.TypeWebhookDeliver, json.RawMessage(`{}`), time.Now(), 5) require.NoError(t, err) h := &recordingHandler{typ: jobs.TypeWebhookDeliver, wantErr: jobs.Permanent(errors.New("4xx"))} var called atomic.Int32 var seenID atomic.Value // uuid.UUID w := jobs.NewWorker(repo, map[jobs.JobType]jobs.Handler{jobs.TypeWebhookDeliver: h}, jobs.WorkerOptions{ ID: "t", PollInterval: 5 * time.Millisecond, Backoff: []time.Duration{time.Second}, Clock: clock.Real(), Log: discardLogger(), OnDeadLetter: func(_ context.Context, j jobs.Job, _ error) { seenID.Store(j.ID) called.Add(1) }, }) cctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() go w.Run(cctx) require.Eventually(t, func() bool { return called.Load() >= 1 }, 500*time.Millisecond, 10*time.Millisecond) cancel() w.Wait() if id, ok := seenID.Load().(uuid.UUID); ok { assert.Equal(t, enqueued.ID, id) } }