feat: implement AI styling foundation and billing scope rework
This commit is contained in:
720
tests/Feature/Api/Event/EventAiEditControllerTest.php
Normal file
720
tests/Feature/Api/Event/EventAiEditControllerTest.php
Normal file
@@ -0,0 +1,720 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Models\Photo;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventAiEditControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guest_can_create_and_fetch_ai_edit_request_with_token(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'ghibli-soft',
|
||||
'name' => 'Soft Animation',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$create = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'style_key' => $style->key,
|
||||
'prompt' => 'Transform into animation style while keeping faces.',
|
||||
'idempotency_key' => 'guest-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.key', $style->key)
|
||||
->assertJsonPath('duplicate', false);
|
||||
|
||||
$requestId = (int) $create->json('data.id');
|
||||
$this->assertGreaterThan(0, $requestId);
|
||||
|
||||
$duplicate = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'style_key' => $style->key,
|
||||
'prompt' => 'Transform into animation style while keeping faces.',
|
||||
'idempotency_key' => 'guest-edit-1',
|
||||
]);
|
||||
|
||||
$duplicate->assertOk()
|
||||
->assertJsonPath('duplicate', true)
|
||||
->assertJsonPath('data.id', $requestId);
|
||||
|
||||
$show = $this->withHeaders(['X-Device-Id' => 'guest-device-1'])
|
||||
->getJson("/api/v1/events/{$token}/ai-edits/{$requestId}");
|
||||
|
||||
$show->assertOk()
|
||||
->assertJsonPath('data.id', $requestId)
|
||||
->assertJsonPath('data.event_id', $event->id)
|
||||
->assertJsonPath('data.photo_id', $photo->id);
|
||||
}
|
||||
|
||||
public function test_guest_prompt_is_blocked_by_safety_policy(): void
|
||||
{
|
||||
AiEditingSetting::flushCache();
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
['blocked_terms' => ['nude', 'explicit']]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-blocked'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-2'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Create an explicit studio image.',
|
||||
'idempotency_key' => 'guest-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_guest_cannot_create_ai_edit_when_feature_is_disabled(): void
|
||||
{
|
||||
AiEditingSetting::flushCache();
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'is_enabled' => false,
|
||||
'status_message' => 'AI editing is temporarily paused.',
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-disabled'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-disabled'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize the photo in watercolor style.',
|
||||
'idempotency_key' => 'guest-edit-disabled-1',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error.code', 'feature_disabled');
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_when_entitlement_is_missing(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachLockedEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-locked'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-locked'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize with cinematic atmosphere.',
|
||||
'idempotency_key' => 'guest-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_guest_can_create_ai_edit_when_ai_addon_is_completed(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = $this->attachLockedEventPackage($event);
|
||||
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'ai_styling_unlock',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-addon'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize photo with warm colors.',
|
||||
'idempotency_key' => 'guest-addon-enabled-1',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('duplicate', false)
|
||||
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_when_ai_addon_is_expired(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = $this->attachLockedEventPackage($event);
|
||||
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'ai_styling_unlock',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now()->subDays(5),
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'features' => ['ai_styling'],
|
||||
'expires_at' => now()->subHour()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-addon-expired'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon-expired'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize photo with warm colors.',
|
||||
'idempotency_key' => 'guest-addon-expired-1',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error.code', 'feature_locked');
|
||||
}
|
||||
|
||||
public function test_guest_can_list_active_ai_styles_when_entitled(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
$this->updateEventAiSettings($event, [
|
||||
'enabled' => true,
|
||||
'allow_custom_prompt' => false,
|
||||
'allowed_style_keys' => ['guest-style-allowed'],
|
||||
'policy_message' => 'Please use the curated style list.',
|
||||
]);
|
||||
|
||||
$allowed = AiStyle::query()->create([
|
||||
'key' => 'guest-style-allowed',
|
||||
'name' => 'Guest Allowed',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'sort' => 2,
|
||||
]);
|
||||
AiStyle::query()->create([
|
||||
'key' => 'guest-style-blocked',
|
||||
'name' => 'Guest Blocked',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'sort' => 1,
|
||||
]);
|
||||
AiStyle::query()->create([
|
||||
'key' => 'guest-style-inactive',
|
||||
'name' => 'Guest Inactive',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => false,
|
||||
'sort' => 1,
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-styles'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.id', $allowed->id)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('meta.required_feature', 'ai_styling')
|
||||
->assertJsonPath('meta.allow_custom_prompt', false)
|
||||
->assertJsonPath('meta.allowed_style_keys.0', 'guest-style-allowed')
|
||||
->assertJsonPath('meta.policy_message', 'Please use the curated style list.');
|
||||
}
|
||||
|
||||
public function test_guest_styles_exclude_premium_style_when_event_is_entitled_via_addon(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = $this->attachLockedEventPackage($event);
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'ai_styling_unlock',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$basicStyle = AiStyle::query()->create([
|
||||
'key' => 'guest-addon-basic-style',
|
||||
'name' => 'Addon Basic Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'is_premium' => false,
|
||||
]);
|
||||
AiStyle::query()->create([
|
||||
'key' => 'guest-addon-premium-style',
|
||||
'name' => 'Addon Premium Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-addon-style-filter'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.key', $basicStyle->key);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_premium_style_edit_when_event_is_entitled_via_addon(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = $this->attachLockedEventPackage($event);
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'ai_styling_unlock',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$premiumStyle = AiStyle::query()->create([
|
||||
'key' => 'guest-addon-premium-submit',
|
||||
'name' => 'Addon Premium Submit',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-addon-premium-submit'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-addon-premium'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'style_key' => $premiumStyle->key,
|
||||
'prompt' => 'Apply premium style.',
|
||||
'idempotency_key' => 'guest-addon-premium-style-submit-1',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonPath('error.code', 'style_not_allowed');
|
||||
}
|
||||
|
||||
public function test_guest_can_create_premium_style_edit_when_event_is_entitled_via_package(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$premiumStyle = AiStyle::query()->create([
|
||||
'key' => 'guest-package-premium-submit',
|
||||
'name' => 'Package Premium Submit',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-package-premium-submit'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-package-premium'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'style_key' => $premiumStyle->key,
|
||||
'prompt' => 'Apply premium style.',
|
||||
'idempotency_key' => 'guest-package-premium-style-submit-1',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.style.key', $premiumStyle->key)
|
||||
->assertJsonPath('data.status', AiEditRequest::STATUS_QUEUED);
|
||||
}
|
||||
|
||||
public function test_guest_styles_filter_required_package_features_from_style_metadata(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$availableStyle = AiStyle::query()->create([
|
||||
'key' => 'guest-required-feature-available',
|
||||
'name' => 'Available Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
AiStyle::query()->create([
|
||||
'key' => 'guest-required-feature-blocked',
|
||||
'name' => 'Blocked Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'required_package_features' => ['advanced_analytics'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-required-feature-filter'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$token}/ai-styles");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.key', $availableStyle->key);
|
||||
}
|
||||
|
||||
public function test_guest_ai_styles_endpoint_is_locked_without_entitlement(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachLockedEventPackage($event);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-styles-locked'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->getJson("/api/v1/events/{$token}/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_guest_returns_idempotency_conflict_for_payload_mismatch(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-idempotency'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$this->withHeaders(['X-Device-Id' => 'guest-device-idempotency'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Create version A.',
|
||||
'idempotency_key' => 'guest-idempotency-conflict-1',
|
||||
])->assertCreated();
|
||||
|
||||
$conflict = $this->withHeaders(['X-Device-Id' => 'guest-device-idempotency'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Create version B.',
|
||||
'idempotency_key' => 'guest-idempotency-conflict-1',
|
||||
]);
|
||||
|
||||
$conflict->assertStatus(409)
|
||||
->assertJsonPath('error.code', 'idempotency_conflict');
|
||||
}
|
||||
|
||||
public function test_guest_cannot_fetch_edit_request_from_another_device(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-device-scope'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$create = $this->withHeaders(['X-Device-Id' => 'guest-device-owner'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize photo.',
|
||||
'idempotency_key' => 'guest-device-scope-1',
|
||||
]);
|
||||
|
||||
$create->assertCreated();
|
||||
$requestId = (int) $create->json('data.id');
|
||||
|
||||
$forbidden = $this->withHeaders(['X-Device-Id' => 'guest-device-other'])
|
||||
->getJson("/api/v1/events/{$token}/ai-edits/{$requestId}");
|
||||
|
||||
$forbidden->assertForbidden()
|
||||
->assertJsonPath('error.code', 'forbidden_request_scope');
|
||||
}
|
||||
|
||||
public function test_guest_submit_endpoint_enforces_rate_limit(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.abuse.guest_submit_per_minute' => 1,
|
||||
'ai-editing.abuse.guest_submit_per_hour' => 50,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-rate-limit'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$first = $this->withHeaders(['X-Device-Id' => 'guest-device-rate-limit'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'First request.',
|
||||
'idempotency_key' => 'guest-rate-limit-1',
|
||||
]);
|
||||
|
||||
$first->assertCreated();
|
||||
|
||||
$second = $this->withHeaders(['X-Device-Id' => 'guest-device-rate-limit'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Second request.',
|
||||
'idempotency_key' => 'guest-rate-limit-2',
|
||||
]);
|
||||
|
||||
$second->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
$this->updateEventAiSettings($event, [
|
||||
'enabled' => false,
|
||||
'policy_message' => 'AI edits are disabled for this event.',
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-event-disabled'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-disabled-event'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize this image.',
|
||||
'idempotency_key' => 'guest-event-disabled-1',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error.code', 'event_feature_disabled')
|
||||
->assertJsonPath('error.message', 'AI edits are disabled for this event.');
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_with_style_outside_allowlist(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$allowed = AiStyle::query()->create([
|
||||
'key' => 'guest-style-allowlisted',
|
||||
'name' => 'Allowlisted',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$blocked = AiStyle::query()->create([
|
||||
'key' => 'guest-style-not-allowlisted',
|
||||
'name' => 'Not allowlisted',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$this->updateEventAiSettings($event, [
|
||||
'enabled' => true,
|
||||
'allow_custom_prompt' => false,
|
||||
'allowed_style_keys' => [$allowed->key],
|
||||
'policy_message' => 'Only curated styles are enabled.',
|
||||
]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-style-allowlist'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-allowlist'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'style_key' => $blocked->key,
|
||||
'prompt' => 'Apply blocked style.',
|
||||
'idempotency_key' => 'guest-style-allowlist-block-1',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonPath('error.code', 'style_not_allowed')
|
||||
->assertJsonPath('error.message', 'Only curated styles are enabled.')
|
||||
->assertJsonPath('error.meta.allowed_style_keys.0', $allowed->key);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user