create(array_merge( AiEditingSetting::defaults(), [ 'runware_mode' => 'live', 'queue_auto_dispatch' => false, ] )); } public function test_submit_builds_image_inference_payload_with_model_defaults_and_constraints(): void { config([ 'services.runware.api_key' => 'test-runware-key', 'services.runware.base_url' => 'https://api.runware.ai/v1', 'filesystems.default' => 'public', 'filesystems.disks.public.url' => 'https://cdn.example.test/storage', 'app.url' => 'https://app.example.test', ]); Storage::fake('public'); $event = Event::factory()->create(['status' => 'published']); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $event->tenant_id, 'status' => 'approved', ]); $sourcePath = 'events/'.$event->slug.'/photos/source-image.jpg'; Storage::disk('public')->put($sourcePath, 'source-image'); $style = AiStyle::query()->create([ 'key' => 'provider-style', 'name' => 'Provider Style', 'provider' => 'runware', 'provider_model' => 'runware:100@1', '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:100@1', 'status' => AiEditRequest::STATUS_QUEUED, 'safety_state' => 'pending', 'prompt' => 'cinematic portrait', 'negative_prompt' => 'blurry', 'input_image_path' => $sourcePath, 'idempotency_key' => 'runware-provider-test-1', 'queued_at' => now(), 'metadata' => [ 'runware' => [ 'generation' => [ 'width' => 1510, 'height' => 986, 'steps' => 32, 'cfg_scale' => 5.5, 'strength' => 0.62, 'output_format' => 'PNG', 'delivery_method' => 'async', ], 'constraints' => [ 'min_width' => 768, 'max_width' => 2048, 'width_step' => 64, 'min_height' => 512, 'max_height' => 2048, 'height_step' => 64, 'min_steps' => 20, 'max_steps' => 60, 'min_cfg_scale' => 1.0, 'max_cfg_scale' => 8.0, 'min_strength' => 0.2, 'max_strength' => 0.9, ], ], ], ]); $capturedPayload = null; Http::fake(function (Request $httpRequest) use (&$capturedPayload) { $payload = $httpRequest->data(); $capturedPayload = is_array($payload) ? $payload : []; $taskUuid = (string) (($capturedPayload[0]['taskUUID'] ?? null) ?: 'task-1'); return Http::response([ 'data' => [ [ 'taskUUID' => $taskUuid, 'status' => 'completed', 'imageURL' => 'https://cdn.runware.ai/outputs/image-1.png', 'imageUUID' => 'image-uuid-1', 'outputFormat' => 'PNG', 'width' => 1536, 'height' => 960, 'cost' => 0.0125, ], [ 'taskUUID' => $taskUuid, 'status' => 'completed', 'imageURL' => 'https://cdn.runware.ai/outputs/image-2.png', 'imageUUID' => 'image-uuid-2', 'outputFormat' => 'PNG', ], ], ], 200); }); $provider = app(RunwareAiImageProvider::class); $result = $provider->submit($request); $this->assertSame('succeeded', $result->status); $this->assertIsArray($capturedPayload); $this->assertIsArray($capturedPayload[0] ?? null); $this->assertSame('imageInference', $capturedPayload[0]['taskType'] ?? null); $this->assertSame('runware:100@1', $capturedPayload[0]['model'] ?? null); $this->assertSame('PNG', $capturedPayload[0]['outputFormat'] ?? null); $this->assertSame('async', $capturedPayload[0]['deliveryMethod'] ?? null); $this->assertSame(1536, $capturedPayload[0]['width'] ?? null); $this->assertSame(960, $capturedPayload[0]['height'] ?? null); $this->assertSame(32, $capturedPayload[0]['steps'] ?? null); $this->assertEquals(5.5, $capturedPayload[0]['CFGScale'] ?? null); $this->assertEquals(0.62, $capturedPayload[0]['strength'] ?? null); $this->assertIsString($capturedPayload[0]['seedImage'] ?? null); $this->assertMatchesRegularExpression('/^https?:\\/\\//', (string) ($capturedPayload[0]['seedImage'] ?? '')); $this->assertSame(2, count($result->outputs)); $this->assertSame('image/png', $result->outputs[0]['mime_type'] ?? null); $this->assertSame('image-uuid-1', $result->outputs[0]['provider_asset_id'] ?? null); $this->assertSame(0.0125, $result->costUsd); } public function test_poll_maps_top_level_runware_errors_to_failed_result(): void { config([ 'services.runware.api_key' => 'test-runware-key', 'services.runware.base_url' => 'https://api.runware.ai/v1', ]); $event = Event::factory()->create(['status' => 'published']); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $event->tenant_id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'provider-poll-style', 'name' => 'Provider Poll Style', 'provider' => 'runware', 'provider_model' => 'runware:100@1', '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:100@1', 'status' => AiEditRequest::STATUS_PROCESSING, 'safety_state' => 'pending', 'prompt' => 'test prompt', 'idempotency_key' => 'runware-provider-test-2', 'queued_at' => now(), 'started_at' => now()->subSeconds(30), ]); Http::fake([ 'https://api.runware.ai/v1' => Http::response([ 'errors' => [ [ 'errorCode' => 'rate_limit_exceeded', 'message' => 'Rate limit exceeded', ], ], ], 429), ]); $provider = app(RunwareAiImageProvider::class); $result = $provider->poll($request, 'provider-task-123'); $this->assertSame('failed', $result->status); $this->assertSame('rate_limit_exceeded', $result->failureCode); $this->assertSame('Rate limit exceeded', $result->failureMessage); $this->assertSame(429, $result->httpStatus); } }