create(array_merge( AiEditingSetting::defaults(), [ 'runware_mode' => 'live', 'queue_auto_dispatch' => false, 'queue_max_polls' => 1, ] )); $event = Event::factory()->create(['status' => 'published']); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $event->tenant_id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'poll-exhaust-style', 'name' => 'Poll Exhaust', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $request = 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' => 'poll-exhaust-1', 'queued_at' => now()->subMinutes(3), 'started_at' => now()->subMinutes(2), ]); $providerRun = AiProviderRun::query()->create([ 'request_id' => $request->id, 'provider' => 'runware', 'attempt' => 1, 'provider_task_id' => 'runware-task-1', 'status' => AiProviderRun::STATUS_RUNNING, 'started_at' => now()->subMinute(), ]); $provider = \Mockery::mock(RunwareAiImageProvider::class); $provider->shouldReceive('poll') ->once() ->withArgs(function (AiEditRequest $polledRequest, string $taskId): bool { return $polledRequest->id > 0 && $taskId === 'runware-task-1'; }) ->andReturn(AiProviderResult::processing('runware-task-1')); $this->app->instance(RunwareAiImageProvider::class, $provider); PollAiEditRequest::dispatchSync($request->id, 'runware-task-1', 1); $request->refresh(); $providerRun->refresh(); $latestRun = $request->providerRuns()->latest('attempt')->first(); $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); $this->assertSame('provider_poll_timeout', $request->failure_code); $this->assertNotNull($request->completed_at); $this->assertNotNull($latestRun); $this->assertSame(AiProviderRun::STATUS_FAILED, $latestRun?->status); $this->assertSame('Polling exhausted after 1 attempt(s).', $latestRun?->error_message); } public function test_poll_job_failed_hook_marks_request_as_failed(): void { $event = Event::factory()->create(['status' => 'published']); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $event->tenant_id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'poll-failed-hook-style', 'name' => 'Poll Failed Hook', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $request = 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' => 'poll-failed-hook-1', 'queued_at' => now()->subMinute(), 'started_at' => now()->subSeconds(30), ]); $job = new PollAiEditRequest($request->id, 'runware-task-2', 2); $job->failed(new RuntimeException('Polling crashed')); $request->refresh(); $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); $this->assertSame('queue_job_failed', $request->failure_code); $this->assertSame('Polling crashed', $request->failure_message); $this->assertNotNull($request->completed_at); } }