938 lines
34 KiB
PHP
938 lines
34 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Tenant;
|
|
|
|
use App\Models\AiEditingSetting;
|
|
use App\Models\AiEditOutput;
|
|
use App\Models\AiEditRequest;
|
|
use App\Models\AiStyle;
|
|
use App\Models\AiUsageLedger;
|
|
use App\Models\Event;
|
|
use App\Models\EventPackage;
|
|
use App\Models\EventPackageAddon;
|
|
use App\Models\Package;
|
|
use App\Models\Photo;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\Feature\Tenant\TenantTestCase;
|
|
|
|
class TenantAiEditControllerTest extends TenantTestCase
|
|
{
|
|
public function test_tenant_admin_can_create_list_and_show_ai_edit_requests(): 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' => '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)
|
|
->assertJsonPath('data.0.version', 1)
|
|
->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_submit_endpoint_enforces_event_scope_rate_limit(): void
|
|
{
|
|
config([
|
|
'ai-editing.abuse.tenant_submit_per_minute' => 10,
|
|
'ai-editing.abuse.tenant_submit_per_hour' => 100,
|
|
'ai-editing.abuse.tenant_submit_per_event_per_minute' => 1,
|
|
]);
|
|
|
|
$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-event-rate-limit-style',
|
|
'name' => 'Tenant Event 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-event-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-event-rate-limit-2',
|
|
]);
|
|
$second->assertStatus(429);
|
|
}
|
|
|
|
public function test_tenant_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
|
|
{
|
|
$event = Event::factory()->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => 'published',
|
|
]);
|
|
$this->attachEntitledEventPackage($event);
|
|
|
|
$tenantSettings = (array) ($this->tenant->settings ?? []);
|
|
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
|
|
$this->tenant->update(['settings' => $tenantSettings]);
|
|
|
|
$photo = Photo::factory()->for($event)->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'status' => 'approved',
|
|
]);
|
|
$style = AiStyle::query()->create([
|
|
'key' => 'tenant-budget-cap-style',
|
|
'name' => 'Tenant Budget Cap',
|
|
'provider' => 'runware',
|
|
'provider_model' => 'runware-default',
|
|
'requires_source_image' => true,
|
|
'is_active' => true,
|
|
]);
|
|
AiUsageLedger::query()->create([
|
|
'tenant_id' => $this->tenant->id,
|
|
'event_id' => $event->id,
|
|
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
|
'quantity' => 1,
|
|
'unit_cost_usd' => 0.02,
|
|
'amount_usd' => 0.02,
|
|
'currency' => 'USD',
|
|
'recorded_at' => now(),
|
|
]);
|
|
|
|
$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-budget-cap-1',
|
|
]);
|
|
|
|
$response->assertForbidden()
|
|
->assertJsonPath('error.code', 'budget_hard_cap_reached')
|
|
->assertJsonPath('error.meta.budget.hard_reached', true);
|
|
}
|
|
|
|
public function test_tenant_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
|
|
{
|
|
config([
|
|
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
|
|
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
|
|
]);
|
|
AiEditingSetting::flushCache();
|
|
AiEditingSetting::query()->create(array_merge(
|
|
AiEditingSetting::defaults(),
|
|
['blocked_terms' => ['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' => 'tenant-escalation-style',
|
|
'name' => 'Tenant Escalation',
|
|
'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.',
|
|
'idempotency_key' => 'tenant-escalation-1',
|
|
]);
|
|
|
|
$response->assertCreated()
|
|
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
|
|
->assertJsonFragment(['abuse_escalation_threshold_reached']);
|
|
|
|
$requestId = (int) $response->json('data.id');
|
|
$editRequest = AiEditRequest::query()->find($requestId);
|
|
|
|
$this->assertNotNull($editRequest);
|
|
$this->assertIsArray($editRequest?->metadata);
|
|
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
|
|
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
|
|
}
|
|
|
|
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)
|
|
->assertJsonPath('data.usage.debit_count', 0)
|
|
->assertJsonPath('data.observability.provider_runs_total', 0)
|
|
->assertJsonPath('data.budget.hard_stop_enabled', true);
|
|
}
|
|
|
|
public function test_tenant_show_serializes_local_output_url_when_storage_path_is_present(): 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-output-url-style',
|
|
'name' => 'Tenant Output URL',
|
|
'provider' => 'runware',
|
|
'provider_model' => 'runware-default',
|
|
'requires_source_image' => true,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$request = 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' => 'passed',
|
|
'prompt' => 'Create edit.',
|
|
'idempotency_key' => 'tenant-output-url-1',
|
|
'queued_at' => now()->subMinute(),
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$storagePath = 'events/demo-event/ai-edits/output-tenant-1.jpg';
|
|
AiEditOutput::query()->create([
|
|
'request_id' => $request->id,
|
|
'provider_asset_id' => 'tenant-output-asset-1',
|
|
'storage_disk' => 'public',
|
|
'storage_path' => $storagePath,
|
|
'provider_url' => 'https://provider.example/output.jpg',
|
|
'mime_type' => 'image/jpeg',
|
|
'is_primary' => true,
|
|
'safety_state' => 'passed',
|
|
'generated_at' => now(),
|
|
]);
|
|
|
|
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/ai-edits/{$request->id}");
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.outputs.0.storage_path', $storagePath)
|
|
->assertJsonPath('data.outputs.0.url', Storage::disk('public')->url($storagePath));
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|