create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'colosseum-bg', 'name' => 'Colosseum', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $create = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Place group photo in Rome.', 'idempotency_key' => 'tenant-edit-1', ]); $create->assertCreated() ->assertJsonPath('data.event_id', $event->id) ->assertJsonPath('data.photo_id', $photo->id) ->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED) ->assertJsonPath('data.style.id', $style->id) ->assertJsonPath('duplicate', false); $requestId = (int) $create->json('data.id'); $this->assertGreaterThan(0, $requestId); $index = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits"); $index->assertOk() ->assertJsonPath('meta.total', 1) ->assertJsonPath('data.0.id', $requestId); $show = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/{$requestId}"); $show->assertOk() ->assertJsonPath('data.id', $requestId) ->assertJsonPath('data.event_id', $event->id); } public function test_tenant_prompt_is_blocked_by_safety_policy(): void { AiEditingSetting::flushCache(); AiEditingSetting::query()->create(array_merge( AiEditingSetting::defaults(), ['blocked_terms' => ['violence', 'weapon']] )); $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'blocked-style', 'name' => 'Blocked Style', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Add weapon effects to the scene.', 'idempotency_key' => 'tenant-edit-blocked-1', ]); $response->assertCreated() ->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED) ->assertJsonPath('data.safety_state', 'blocked') ->assertJsonPath('data.failure_code', 'prompt_policy_blocked') ->assertJsonPath('data.safety_reasons.0', 'prompt_blocked_term'); } public function test_tenant_cannot_create_ai_edit_when_feature_is_disabled(): void { AiEditingSetting::flushCache(); AiEditingSetting::query()->create(array_merge( AiEditingSetting::defaults(), ['is_enabled' => false] )); $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'disabled-style', 'name' => 'Disabled Style', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Apply style transfer.', 'idempotency_key' => 'tenant-edit-disabled-1', ]); $response->assertForbidden() ->assertJsonPath('error.code', 'feature_disabled'); } public function test_tenant_cannot_create_ai_edit_when_entitlement_is_missing(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachLockedEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'locked-style', 'name' => 'Locked Style', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Apply style transfer.', 'idempotency_key' => 'tenant-edit-locked-1', ]); $response->assertForbidden() ->assertJsonPath('error.code', 'feature_locked') ->assertJsonPath('error.meta.required_feature', 'ai_styling') ->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock'); } public function test_tenant_can_create_ai_edit_when_ai_addon_is_completed(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $eventPackage = $this->attachLockedEventPackage($event); EventPackageAddon::query()->create([ 'event_package_id' => $eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $this->tenant->id, 'addon_key' => 'ai_styling_unlock', 'quantity' => 1, 'status' => 'completed', 'purchased_at' => now(), ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'addon-enabled-style', 'name' => 'Addon Enabled', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Add a realistic city background.', 'idempotency_key' => 'tenant-addon-entitled-1', ]); $response->assertCreated() ->assertJsonPath('duplicate', false) ->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED); } public function test_tenant_cannot_create_ai_edit_when_ai_addon_is_expired(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $eventPackage = $this->attachLockedEventPackage($event); EventPackageAddon::query()->create([ 'event_package_id' => $eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $this->tenant->id, 'addon_key' => 'ai_styling_unlock', 'quantity' => 1, 'status' => 'completed', 'purchased_at' => now()->subDays(10), 'metadata' => [ 'entitlements' => [ 'features' => ['ai_styling'], 'expires_at' => now()->subDay()->toIso8601String(), ], ], ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'addon-expired-style', 'name' => 'Addon Expired', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Add a realistic city background.', 'idempotency_key' => 'tenant-addon-expired-1', ]); $response->assertForbidden() ->assertJsonPath('error.code', 'feature_locked'); } public function test_tenant_can_list_active_ai_styles_when_entitled(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $this->updateEventAiSettings($event, [ 'enabled' => false, 'allow_custom_prompt' => false, 'allowed_style_keys' => ['tenant-style-active'], 'policy_message' => 'AI is currently paused for this event.', ]); $active = AiStyle::query()->create([ 'key' => 'tenant-style-active', 'name' => 'Tenant Active', 'provider' => 'runware', 'provider_model' => 'runware-default', 'is_active' => true, 'sort' => 2, ]); AiStyle::query()->create([ 'key' => 'tenant-style-inactive', 'name' => 'Tenant Inactive', 'provider' => 'runware', 'provider_model' => 'runware-default', 'is_active' => false, 'sort' => 1, ]); $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles"); $response->assertOk() ->assertJsonPath('data.0.id', $active->id) ->assertJsonCount(1, 'data') ->assertJsonPath('meta.required_feature', 'ai_styling') ->assertJsonPath('meta.event_enabled', false) ->assertJsonPath('meta.allow_custom_prompt', false) ->assertJsonPath('meta.allowed_style_keys.0', 'tenant-style-active') ->assertJsonPath('meta.policy_message', 'AI is currently paused for this event.'); } public function test_tenant_styles_exclude_premium_style_when_event_is_entitled_via_addon(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $eventPackage = $this->attachLockedEventPackage($event); EventPackageAddon::query()->create([ 'event_package_id' => $eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $this->tenant->id, 'addon_key' => 'ai_styling_unlock', 'quantity' => 1, 'status' => 'completed', 'purchased_at' => now(), ]); $basicStyle = AiStyle::query()->create([ 'key' => 'tenant-addon-basic-style', 'name' => 'Tenant Addon Basic', 'provider' => 'runware', 'provider_model' => 'runware-default', 'is_active' => true, 'is_premium' => false, ]); AiStyle::query()->create([ 'key' => 'tenant-addon-premium-style', 'name' => 'Tenant Addon Premium', 'provider' => 'runware', 'provider_model' => 'runware-default', 'is_active' => true, 'is_premium' => true, ]); $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles"); $response->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.key', $basicStyle->key); } public function test_tenant_cannot_create_premium_style_edit_when_event_is_entitled_via_addon(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $eventPackage = $this->attachLockedEventPackage($event); EventPackageAddon::query()->create([ 'event_package_id' => $eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $this->tenant->id, 'addon_key' => 'ai_styling_unlock', 'quantity' => 1, 'status' => 'completed', 'purchased_at' => now(), ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $premiumStyle = AiStyle::query()->create([ 'key' => 'tenant-addon-premium-submit', 'name' => 'Tenant Addon Premium Submit', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, 'is_premium' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $premiumStyle->id, 'prompt' => 'Apply premium style.', 'idempotency_key' => 'tenant-addon-premium-style-submit-1', ]); $response->assertUnprocessable() ->assertJsonPath('error.code', 'style_not_allowed'); } public function test_tenant_can_create_premium_style_edit_when_event_is_entitled_via_package(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $premiumStyle = AiStyle::query()->create([ 'key' => 'tenant-package-premium-submit', 'name' => 'Tenant Package Premium Submit', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, 'is_premium' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $premiumStyle->id, 'prompt' => 'Apply premium style.', 'idempotency_key' => 'tenant-package-premium-style-submit-1', ]); $response->assertCreated() ->assertJsonPath('data.style.id', $premiumStyle->id) ->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED); } public function test_tenant_ai_styles_endpoint_is_locked_without_entitlement(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachLockedEventPackage($event); $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-styles"); $response->assertForbidden() ->assertJsonPath('error.code', 'feature_locked') ->assertJsonPath('error.meta.required_feature', 'ai_styling') ->assertJsonPath('error.meta.addon_keys.0', 'ai_styling_unlock'); } public function test_tenant_returns_idempotency_conflict_for_payload_mismatch(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $primaryStyle = AiStyle::query()->create([ 'key' => 'tenant-idempotency-style-a', 'name' => 'Style A', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $secondaryStyle = AiStyle::query()->create([ 'key' => 'tenant-idempotency-style-b', 'name' => 'Style B', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $primaryStyle->id, 'prompt' => 'Create version A.', 'idempotency_key' => 'tenant-idempotency-conflict-1', ])->assertCreated(); $conflict = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $secondaryStyle->id, 'prompt' => 'Create version B.', 'idempotency_key' => 'tenant-idempotency-conflict-1', ]); $conflict->assertStatus(409) ->assertJsonPath('error.code', 'idempotency_conflict'); } public function test_tenant_submit_endpoint_enforces_rate_limit(): void { config([ 'ai-editing.abuse.tenant_submit_per_minute' => 1, 'ai-editing.abuse.tenant_submit_per_hour' => 50, ]); $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'tenant-rate-limit-style', 'name' => 'Rate Limit', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $first = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'First request.', 'idempotency_key' => 'tenant-rate-limit-1', ]); $first->assertCreated(); $second = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Second request.', 'idempotency_key' => 'tenant-rate-limit-2', ]); $second->assertStatus(429); } public function test_tenant_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $this->updateEventAiSettings($event, [ 'enabled' => false, 'policy_message' => 'AI editing is disabled for this event by the organizer.', ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'tenant-event-disabled-style', 'name' => 'Disabled', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $style->id, 'prompt' => 'Apply AI style.', 'idempotency_key' => 'tenant-event-disabled-ai-1', ]); $response->assertForbidden() ->assertJsonPath('error.code', 'event_feature_disabled') ->assertJsonPath('error.message', 'AI editing is disabled for this event by the organizer.'); } public function test_tenant_cannot_create_ai_edit_with_style_outside_allowlist(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $allowedStyle = AiStyle::query()->create([ 'key' => 'tenant-allowed-style', 'name' => 'Allowed', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $blockedStyle = AiStyle::query()->create([ 'key' => 'tenant-blocked-style', 'name' => 'Blocked', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); $this->updateEventAiSettings($event, [ 'enabled' => true, 'allowed_style_keys' => [$allowedStyle->key], 'policy_message' => 'Only curated styles are enabled for this event.', ]); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ 'photo_id' => $photo->id, 'style_id' => $blockedStyle->id, 'prompt' => 'Apply blocked style.', 'idempotency_key' => 'tenant-style-allowlist-block-1', ]); $response->assertUnprocessable() ->assertJsonPath('error.code', 'style_not_allowed') ->assertJsonPath('error.message', 'Only curated styles are enabled for this event.') ->assertJsonPath('error.meta.allowed_style_keys.0', $allowedStyle->key); } public function test_tenant_can_fetch_ai_usage_summary(): void { $event = Event::factory()->create([ 'tenant_id' => $this->tenant->id, 'status' => 'published', ]); $this->attachEntitledEventPackage($event); $photo = Photo::factory()->for($event)->create([ 'tenant_id' => $this->tenant->id, 'status' => 'approved', ]); $style = AiStyle::query()->create([ 'key' => 'tenant-summary-style', 'name' => 'Summary', 'provider' => 'runware', 'provider_model' => 'runware-default', 'requires_source_image' => true, 'is_active' => true, ]); AiEditRequest::query()->create([ 'tenant_id' => $this->tenant->id, 'event_id' => $event->id, 'photo_id' => $photo->id, 'style_id' => $style->id, 'provider' => 'runware', 'provider_model' => 'runware-default', 'status' => AiEditRequest::STATUS_SUCCEEDED, 'safety_state' => 'approved', 'prompt' => 'Request A', 'idempotency_key' => 'tenant-summary-1', 'queued_at' => now()->subMinutes(3), 'completed_at' => now()->subMinutes(2), ]); AiEditRequest::query()->create([ 'tenant_id' => $this->tenant->id, 'event_id' => $event->id, 'photo_id' => $photo->id, 'style_id' => $style->id, 'provider' => 'runware', 'provider_model' => 'runware-default', 'status' => AiEditRequest::STATUS_FAILED, 'safety_state' => 'approved', 'prompt' => 'Request B', 'idempotency_key' => 'tenant-summary-2', 'queued_at' => now()->subMinutes(1), 'completed_at' => now(), 'failure_code' => 'provider_error', ]); $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/summary"); $response->assertOk() ->assertJsonPath('data.event_id', $event->id) ->assertJsonPath('data.total', 2) ->assertJsonPath('data.status_counts.succeeded', 1) ->assertJsonPath('data.status_counts.failed', 1) ->assertJsonPath('data.failed_total', 1); } private function attachEntitledEventPackage(Event $event): EventPackage { $package = Package::factory()->endcustomer()->create([ 'features' => ['basic_uploads', 'ai_styling'], ]); return EventPackage::query()->create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 0, 'gallery_expires_at' => now()->addDays(30), ]); } private function attachLockedEventPackage(Event $event): EventPackage { $package = Package::factory()->endcustomer()->create([ 'features' => ['basic_uploads', 'custom_tasks'], ]); return EventPackage::query()->create([ 'event_id' => $event->id, 'package_id' => $package->id, 'purchased_price' => $package->price, 'purchased_at' => now(), 'used_photos' => 0, 'used_guests' => 0, 'gallery_expires_at' => now()->addDays(30), ]); } private function updateEventAiSettings(Event $event, array $aiSettings): void { $settings = is_array($event->settings) ? $event->settings : []; $settings['ai_editing'] = $aiSettings; $event->update(['settings' => $settings]); } }