package usecase import ( "context" "testing" "haixun-backend/internal/library/clock" "haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/enum" "go.mongodb.org/mongo-driver/bson/primitive" ) func TestRetryRun_SchedulesBackoff(t *testing.T) { ctx := context.Background() template := demoTemplate() template.RetryPolicy = entity.RetryPolicy{ MaxAttempts: 3, BackoffSeconds: []int{30, 60}, } jobID := primitive.NewObjectID() runs := newMemoryRunRepo(&entity.Run{ ID: jobID, TemplateType: template.Type, Status: enum.RunStatusFailed, WorkerType: "go", Attempt: 1, MaxAttempts: 3, Progress: entity.RunProgress{ Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}}, }, }) queue := newMemoryQueueRepo() uc := testUseCaseFull(template, runs, nil, queue) updated, err := uc.RetryRun(ctx, jobID.Hex()) if err != nil { t.Fatalf("RetryRun() error = %v", err) } if updated.Status != enum.RunStatusPending { t.Fatalf("status = %s, want pending", updated.Status) } if updated.ScheduledAt == nil { t.Fatal("expected scheduled_at for backoff retry") } if *updated.ScheduledAt <= clock.NowUnixNano() { t.Fatal("scheduled_at should be in the future") } if len(queue.queued("go")) != 0 { t.Fatal("backoff retry should not enqueue immediately") } } func TestRetryRun_BackoffReschedule_DoesNotClobberConcurrentCancel(t *testing.T) { ctx := context.Background() template := demoTemplate() template.RetryPolicy = entity.RetryPolicy{ MaxAttempts: 3, BackoffSeconds: []int{30, 60}, } jobID := primitive.NewObjectID() runs := newMemoryRunRepo(&entity.Run{ ID: jobID, TemplateType: template.Type, Status: enum.RunStatusFailed, WorkerType: "go", Attempt: 1, MaxAttempts: 3, Progress: entity.RunProgress{ Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}}, }, }) queue := newMemoryQueueRepo() uc := testUseCaseFull(template, runs, nil, queue) // Simulate a user cancelling the job right after RetryRun transitions it // to "pending" but before the backoff reschedule writes ScheduledAt back. // The reschedule write must not silently revive the cancelled job. runs.raceAfterUpdateIfStatus = func() { runs.run.Status = enum.RunStatusCancelled } _, err := uc.RetryRun(ctx, jobID.Hex()) if err == nil { t.Fatal("expected error when backoff reschedule races with a concurrent cancel") } if runs.run.Status != enum.RunStatusCancelled { t.Fatalf("status = %s, want cancelled (must not be revived to pending)", runs.run.Status) } }