createStuckRequests(); Queue::fake(); $this->artisan('ai-edits:recover-stuck', [ '--minutes' => 10, '--requeue' => true, ])->assertExitCode(0); Queue::assertPushed(ProcessAiEditRequest::class, 1); Queue::assertPushed(PollAiEditRequest::class, 1); $this->assertDatabaseHas('ai_edit_requests', [ 'id' => $queuedRequest->id, 'status' => AiEditRequest::STATUS_QUEUED, ]); $this->assertDatabaseHas('ai_edit_requests', [ 'id' => $processingRequest->id, 'status' => AiEditRequest::STATUS_PROCESSING, ]); } public function test_command_can_mark_stuck_requests_as_failed(): void { [$queuedRequest, $processingRequest] = $this->createStuckRequests(); $this->artisan('ai-edits:recover-stuck', [ '--minutes' => 10, '--fail' => true, ])->assertExitCode(0); $this->assertDatabaseHas('ai_edit_requests', [ 'id' => $queuedRequest->id, 'status' => AiEditRequest::STATUS_FAILED, 'failure_code' => 'operator_recovery_marked_failed', ]); $this->assertDatabaseHas('ai_edit_requests', [ 'id' => $processingRequest->id, 'status' => AiEditRequest::STATUS_FAILED, 'failure_code' => 'operator_recovery_marked_failed', ]); } /** * @return array{0: AiEditRequest, 1: AiEditRequest} */ private function createStuckRequests(): array { $event = Event::factory()->create(['status' => 'published']); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $event->tenant_id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'recovery-style', 'name' => 'Recovery Style', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $queued = AiEditRequest::query()->create([ 'tenant_id' => $event->tenant_id, 'event_id' => $event->id, 'photo_id' => $photo->id, 'style_id' => $style->id, 'provider' => 'runware', 'provider_model' => 'runware-default', 'status' => AiEditRequest::STATUS_QUEUED, 'safety_state' => 'pending', 'prompt' => 'Transform image style.', 'idempotency_key' => 'stuck-queued-1', 'queued_at' => now()->subMinutes(45), 'updated_at' => now()->subMinutes(45), ]); $processing = AiEditRequest::query()->create([ 'tenant_id' => $event->tenant_id, 'event_id' => $event->id, 'photo_id' => $photo->id, 'style_id' => $style->id, 'provider' => 'runware', 'provider_model' => 'runware-default', 'status' => AiEditRequest::STATUS_PROCESSING, 'safety_state' => 'pending', 'prompt' => 'Transform image style.', 'idempotency_key' => 'stuck-processing-1', 'queued_at' => now()->subMinutes(50), 'started_at' => now()->subMinutes(40), 'updated_at' => now()->subMinutes(40), ]); AiProviderRun::query()->create([ 'request_id' => $processing->id, 'provider' => 'runware', 'attempt' => 1, 'provider_task_id' => 'runware-task-recovery-1', 'status' => AiProviderRun::STATUS_RUNNING, 'started_at' => now()->subMinutes(39), ]); return [$queued, $processing]; } }