false]); } public function test_it_marks_request_failed_when_poll_attempts_are_exhausted(): void { AiEditingSetting::query()->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); } public function test_it_persists_polled_output_to_local_storage_when_enabled(): void { config([ 'ai-editing.outputs.enabled' => true, 'ai-editing.outputs.allowed_hosts' => ['cdn.runware.ai'], 'filesystems.default' => 'public', 'watermark.base.asset' => 'branding/test-watermark.png', ]); Storage::fake('public'); Storage::disk('public')->put('branding/test-watermark.png', $this->tinyPngBinary()); AiEditingSetting::query()->create(array_merge( AiEditingSetting::defaults(), [ 'runware_mode' => 'live', 'queue_auto_dispatch' => false, 'queue_max_polls' => 3, ] )); Http::fake([ 'https://cdn.runware.ai/*' => Http::response($this->tinyPngBinary(), 200, [ 'Content-Type' => 'image/png', ]), ]); $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-storage-style', 'name' => 'Poll Storage', '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-storage-1', 'queued_at' => now()->subMinutes(2), '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-storage'; }) ->andReturn(AiProviderResult::succeeded(outputs: [[ 'provider_url' => 'https://cdn.runware.ai/outputs/poll-image.png', 'provider_asset_id' => 'poll-asset-123', 'mime_type' => 'image/png', ]])); $this->app->instance(RunwareAiImageProvider::class, $provider); PollAiEditRequest::dispatchSync($request->id, 'runware-task-storage', 1); $request->refresh(); $output = $request->outputs()->first(); $this->assertSame(AiEditRequest::STATUS_SUCCEEDED, $request->status); $this->assertNotNull($output); $this->assertNotNull($output?->storage_disk); $this->assertNotNull($output?->storage_path); $this->assertNotSame('', trim((string) $output?->storage_path)); $this->assertTrue(Storage::disk((string) $output?->storage_disk)->exists((string) $output?->storage_path)); } private function tinyPngBinary(): string { return (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+XnV0AAAAASUVORK5CYII='); } }