feat: implement AI styling foundation and billing scope rework
This commit is contained in:
147
tests/Feature/Ai/AiEditingDataModelTest.php
Normal file
147
tests/Feature/Ai/AiEditingDataModelTest.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Ai;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiEditingDataModelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_ai_tables_exist_with_expected_key_columns(): void
|
||||
{
|
||||
$this->assertTrue(Schema::hasTable('ai_styles'));
|
||||
$this->assertTrue(Schema::hasTable('ai_edit_requests'));
|
||||
$this->assertTrue(Schema::hasTable('ai_edit_outputs'));
|
||||
$this->assertTrue(Schema::hasTable('ai_provider_runs'));
|
||||
$this->assertTrue(Schema::hasTable('ai_usage_ledgers'));
|
||||
|
||||
foreach ([
|
||||
'key',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'requires_source_image',
|
||||
'is_premium',
|
||||
] as $column) {
|
||||
$this->assertTrue(Schema::hasColumn('ai_styles', $column));
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'photo_id',
|
||||
'style_id',
|
||||
'provider',
|
||||
'idempotency_key',
|
||||
'safety_state',
|
||||
'status',
|
||||
] as $column) {
|
||||
$this->assertTrue(Schema::hasColumn('ai_edit_requests', $column));
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'request_id',
|
||||
'provider',
|
||||
'provider_task_id',
|
||||
'request_payload',
|
||||
'response_payload',
|
||||
'cost_usd',
|
||||
] as $column) {
|
||||
$this->assertTrue(Schema::hasColumn('ai_provider_runs', $column));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_ai_edit_flow_records_request_run_output_and_usage_with_relations(): void
|
||||
{
|
||||
$photo = Photo::factory()->create();
|
||||
$event = $photo->event;
|
||||
$tenant = $event->tenant;
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'bg-colosseum',
|
||||
'name' => 'Colosseum Background',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'requested_by_user_id' => $user->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Replace background with Colosseum in Rome.',
|
||||
'idempotency_key' => 'req-'.$photo->id.'-1',
|
||||
'queued_at' => now(),
|
||||
'metadata' => ['source' => 'guest_pwa'],
|
||||
]);
|
||||
|
||||
$run = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => 'runware',
|
||||
'attempt' => 1,
|
||||
'provider_task_id' => 'task-123',
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'request_payload' => ['positivePrompt' => 'Rome Colosseum'],
|
||||
'response_payload' => ['status' => 'processing'],
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$output = AiEditOutput::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'storage_disk' => 'public',
|
||||
'storage_path' => 'ai/outputs/final.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'width' => 1024,
|
||||
'height' => 1024,
|
||||
'is_primary' => true,
|
||||
'generated_at' => now(),
|
||||
'metadata' => ['variant' => 'v1'],
|
||||
]);
|
||||
|
||||
$ledger = AiUsageLedger::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'request_id' => $request->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 0.015,
|
||||
'amount_usd' => 0.015,
|
||||
'recorded_at' => now(),
|
||||
'metadata' => ['provider' => 'runware'],
|
||||
]);
|
||||
|
||||
$request->refresh();
|
||||
$run->refresh();
|
||||
$output->refresh();
|
||||
$ledger->refresh();
|
||||
|
||||
$this->assertSame($style->id, $request->style?->id);
|
||||
$this->assertSame($tenant->id, $request->tenant?->id);
|
||||
$this->assertSame($event->id, $request->event?->id);
|
||||
$this->assertSame($photo->id, $request->photo?->id);
|
||||
$this->assertSame(1, $request->providerRuns()->count());
|
||||
$this->assertSame(1, $request->outputs()->count());
|
||||
$this->assertSame(1, $request->usageLedgers()->count());
|
||||
$this->assertIsArray($run->request_payload);
|
||||
$this->assertIsArray($run->response_payload);
|
||||
$this->assertIsArray($ledger->metadata);
|
||||
$this->assertGreaterThan(0, (float) $ledger->amount_usd);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
99
tests/Feature/Api/Event/EventPublicCapabilitiesTest.php
Normal file
99
tests/Feature/Api/Event/EventPublicCapabilitiesTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Event;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventPublicCapabilitiesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_event_response_hides_ai_capability_when_not_entitled(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachPackage($event, ['basic_uploads', 'custom_tasks']);
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'public-ai-capability-locked'])
|
||||
->token;
|
||||
|
||||
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-locked-device')
|
||||
->getJson("/api/v1/events/{$token}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('capabilities.ai_styling', false)
|
||||
->assertJsonPath('capabilities.ai_styling_granted_by', null);
|
||||
}
|
||||
|
||||
public function test_event_response_exposes_ai_capability_when_package_includes_feature(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'public-ai-capability-package'])
|
||||
->token;
|
||||
|
||||
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-package-device')
|
||||
->getJson("/api/v1/events/{$token}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('capabilities.ai_styling', true)
|
||||
->assertJsonPath('capabilities.ai_styling_granted_by', 'package');
|
||||
}
|
||||
|
||||
public function test_event_response_exposes_ai_capability_when_addon_unlock_is_completed(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$eventPackage = $this->attachPackage($event, ['basic_uploads', 'custom_tasks']);
|
||||
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(),
|
||||
]);
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'public-ai-capability-addon'])
|
||||
->token;
|
||||
|
||||
$response = $this->withHeader('X-Device-Id', 'public-ai-capability-addon-device')
|
||||
->getJson("/api/v1/events/{$token}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('capabilities.ai_styling', true)
|
||||
->assertJsonPath('capabilities.ai_styling_granted_by', 'addon');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $features
|
||||
*/
|
||||
private function attachPackage(Event $event, array $features): EventPackage
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => $features,
|
||||
]);
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -99,4 +99,130 @@ class BillingAddonHistoryTest extends TenantTestCase
|
||||
$response->assertJsonPath('data.0.event.slug', $event->slug);
|
||||
$response->assertJsonPath('data.1.id', $firstAddon->id);
|
||||
}
|
||||
|
||||
public function test_tenant_can_filter_addon_history_by_event_scope(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create();
|
||||
|
||||
$firstEvent = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'first-event',
|
||||
'name' => ['de' => 'Erstes Event', 'en' => 'First Event'],
|
||||
]);
|
||||
$secondEvent = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'second-event',
|
||||
'name' => ['de' => 'Zweites Event', 'en' => 'Second Event'],
|
||||
]);
|
||||
|
||||
$firstEventPackage = EventPackage::create([
|
||||
'event_id' => $firstEvent->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subWeek(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(20),
|
||||
]);
|
||||
$secondEventPackage = EventPackage::create([
|
||||
'event_id' => $secondEvent->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subWeek(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(20),
|
||||
]);
|
||||
|
||||
$scopedAddon = EventPackageAddon::create([
|
||||
'event_package_id' => $firstEventPackage->id,
|
||||
'event_id' => $firstEvent->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'addon_key' => 'extra_photos_100',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'amount' => 29.00,
|
||||
'currency' => 'EUR',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
EventPackageAddon::create([
|
||||
'event_package_id' => $secondEventPackage->id,
|
||||
'event_id' => $secondEvent->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'addon_key' => 'extra_guests_100',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'amount' => 39.00,
|
||||
'currency' => 'EUR',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/billing/addons?event_id={$firstEvent->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.total', 1);
|
||||
$response->assertJsonPath('data.0.id', $scopedAddon->id);
|
||||
$response->assertJsonPath('meta.scope.type', 'event');
|
||||
$response->assertJsonPath('meta.scope.event.id', $firstEvent->id);
|
||||
$response->assertJsonPath('meta.scope.event.slug', 'first-event');
|
||||
}
|
||||
|
||||
public function test_tenant_can_filter_addon_history_by_event_slug_and_status(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create();
|
||||
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'slug' => 'winter-ball',
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now()->subWeek(),
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(20),
|
||||
]);
|
||||
|
||||
EventPackageAddon::create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'addon_key' => 'extra_photos_100',
|
||||
'quantity' => 1,
|
||||
'status' => 'pending',
|
||||
'amount' => 19.00,
|
||||
'currency' => 'EUR',
|
||||
'purchased_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$completedAddon = EventPackageAddon::create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'addon_key' => 'extra_guests_100',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'amount' => 29.00,
|
||||
'currency' => 'EUR',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons?event_slug=winter-ball&status=completed');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.total', 1);
|
||||
$response->assertJsonPath('data.0.id', $completedAddon->id);
|
||||
$response->assertJsonPath('data.0.status', 'completed');
|
||||
$response->assertJsonPath('meta.scope.type', 'event');
|
||||
$response->assertJsonPath('meta.scope.event.slug', 'winter-ball');
|
||||
}
|
||||
|
||||
public function test_tenant_gets_not_found_for_unknown_event_scope_filter(): void
|
||||
{
|
||||
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons?event_slug=missing-event');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonPath('message', 'Event scope not found.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,5 +48,43 @@ class EventAddonsSummaryTest extends TenantTestCase
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.addons.0.key', 'extra_guests_100');
|
||||
$response->assertJsonPath('data.addons.0.extra_guests', 100);
|
||||
$response->assertJsonPath('data.capabilities.ai_styling', false);
|
||||
}
|
||||
|
||||
public function test_event_resource_reports_ai_styling_capability_when_addon_is_completed(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
$event = Event::factory()->for($this->tenant)->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::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),
|
||||
]);
|
||||
|
||||
EventPackageAddon::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(),
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.capabilities.ai_styling', true);
|
||||
$response->assertJsonPath('data.capabilities.ai_styling_granted_by', 'addon');
|
||||
}
|
||||
}
|
||||
|
||||
731
tests/Feature/Api/Tenant/TenantAiEditControllerTest.php
Normal file
731
tests/Feature/Api/Tenant/TenantAiEditControllerTest.php
Normal file
@@ -0,0 +1,731 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Tenant;
|
||||
|
||||
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 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)
|
||||
->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]);
|
||||
}
|
||||
}
|
||||
191
tests/Feature/Jobs/ProcessAiEditRequestTest.php
Normal file
191
tests/Feature/Jobs/ProcessAiEditRequestTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Jobs;
|
||||
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProcessAiEditRequestTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
AiEditingSetting::flushCache();
|
||||
}
|
||||
|
||||
public function test_it_processes_ai_edit_request_with_fake_runware_provider(): void
|
||||
{
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'runware_mode' => 'fake',
|
||||
'queue_auto_dispatch' => false,
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'fake-style',
|
||||
'name' => 'Fake Style',
|
||||
'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_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'job-fake-1',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
|
||||
ProcessAiEditRequest::dispatchSync($request->id);
|
||||
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_SUCCEEDED, $request->status);
|
||||
$this->assertNotNull($request->started_at);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertSame(1, $request->outputs()->count());
|
||||
$this->assertSame(1, $request->providerRuns()->count());
|
||||
$this->assertSame('succeeded', $request->providerRuns()->first()?->status);
|
||||
$this->assertSame(1, $request->usageLedgers()->count());
|
||||
$this->assertSame(AiUsageLedger::TYPE_DEBIT, $request->usageLedgers()->first()?->entry_type);
|
||||
$this->assertSame('unentitled', $request->usageLedgers()->first()?->package_context);
|
||||
|
||||
ProcessAiEditRequest::dispatchSync($request->id);
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(1, $request->usageLedgers()->count());
|
||||
}
|
||||
|
||||
public function test_it_marks_request_failed_when_runware_is_not_configured(): void
|
||||
{
|
||||
config([
|
||||
'services.runware.api_key' => null,
|
||||
]);
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'runware_mode' => 'live',
|
||||
'queue_auto_dispatch' => false,
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'live-style',
|
||||
'name' => 'Live Style',
|
||||
'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_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'job-live-1',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
|
||||
ProcessAiEditRequest::dispatchSync($request->id);
|
||||
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
|
||||
$this->assertSame('provider_not_configured', $request->failure_code);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertSame(0, $request->outputs()->count());
|
||||
$this->assertSame(1, $request->providerRuns()->count());
|
||||
$this->assertSame('failed', $request->providerRuns()->first()?->status);
|
||||
}
|
||||
|
||||
public function test_it_blocks_request_when_provider_flags_output_as_unsafe(): void
|
||||
{
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'runware_mode' => 'fake',
|
||||
'queue_auto_dispatch' => false,
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'unsafe-style',
|
||||
'name' => 'Unsafe Style',
|
||||
'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_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'job-fake-unsafe-1',
|
||||
'queued_at' => now(),
|
||||
'metadata' => ['fake_nsfw' => true],
|
||||
]);
|
||||
|
||||
ProcessAiEditRequest::dispatchSync($request->id);
|
||||
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_BLOCKED, $request->status);
|
||||
$this->assertSame('blocked', $request->safety_state);
|
||||
$this->assertSame('output_policy_blocked', $request->failure_code);
|
||||
$this->assertSame(['provider_nsfw_content'], $request->safety_reasons);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertSame(0, $request->outputs()->count());
|
||||
$this->assertSame(1, $request->providerRuns()->count());
|
||||
}
|
||||
}
|
||||
16
tests/Unit/PackageResourceFeatureLabelTest.php
Normal file
16
tests/Unit/PackageResourceFeatureLabelTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Filament\Resources\PackageResource;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PackageResourceFeatureLabelTest extends TestCase
|
||||
{
|
||||
public function test_it_formats_ai_styling_feature_label_for_display(): void
|
||||
{
|
||||
$formatted = PackageResource::formatFeaturesForDisplay(['ai_styling', 'custom_branding']);
|
||||
|
||||
$this->assertSame('AI-Styling, Eigenes Branding', $formatted);
|
||||
}
|
||||
}
|
||||
158
tests/Unit/Services/AiStyleAccessServiceTest.php
Normal file
158
tests/Unit/Services/AiStyleAccessServiceTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Services\AiEditing\AiStyleAccessService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiStyleAccessServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_package_entitlement_can_use_premium_style(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'premium-package-style',
|
||||
'name' => 'Premium Package Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
]);
|
||||
|
||||
$service = app(AiStyleAccessService::class);
|
||||
|
||||
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
|
||||
}
|
||||
|
||||
public function test_addon_entitlement_cannot_use_premium_style_by_default(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$eventPackage = $this->attachPackage($event, ['basic_uploads']);
|
||||
$this->attachAddon($event, $eventPackage);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'premium-addon-blocked',
|
||||
'name' => 'Premium Addon Blocked',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
]);
|
||||
|
||||
$service = app(AiStyleAccessService::class);
|
||||
|
||||
$this->assertFalse($service->canUseStyle($event->fresh(), $style));
|
||||
}
|
||||
|
||||
public function test_addon_entitlement_can_use_premium_style_when_style_metadata_allows_it(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$eventPackage = $this->attachPackage($event, ['basic_uploads']);
|
||||
$this->attachAddon($event, $eventPackage);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'premium-addon-allowed',
|
||||
'name' => 'Premium Addon Allowed',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'is_premium' => true,
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'allow_with_addon' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(AiStyleAccessService::class);
|
||||
|
||||
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
|
||||
}
|
||||
|
||||
public function test_required_package_feature_blocks_style_when_missing(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$this->attachPackage($event, ['basic_uploads', 'ai_styling']);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'advanced-only-style',
|
||||
'name' => 'Advanced Only',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'required_package_features' => ['advanced_analytics'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(AiStyleAccessService::class);
|
||||
|
||||
$this->assertFalse($service->canUseStyle($event->fresh(), $style));
|
||||
}
|
||||
|
||||
public function test_required_package_feature_allows_style_when_present(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$this->attachPackage($event, ['basic_uploads', 'ai_styling', 'advanced_analytics']);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'advanced-available-style',
|
||||
'name' => 'Advanced Available',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'required_package_features' => ['advanced_analytics'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(AiStyleAccessService::class);
|
||||
|
||||
$this->assertTrue($service->canUseStyle($event->fresh(), $style));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $features
|
||||
*/
|
||||
private function attachPackage(Event $event, array $features): EventPackage
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => $features,
|
||||
]);
|
||||
|
||||
return EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
}
|
||||
|
||||
private function attachAddon(Event $event, EventPackage $eventPackage): EventPackageAddon
|
||||
{
|
||||
return 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
221
tests/Unit/Services/AiStylingEntitlementServiceTest.php
Normal file
221
tests/Unit/Services/AiStylingEntitlementServiceTest.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiStylingEntitlementServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_grants_access_via_package_feature(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads', 'ai_styling'],
|
||||
]);
|
||||
|
||||
EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$result = $service->resolveForEvent($event->fresh());
|
||||
|
||||
$this->assertTrue($result['allowed']);
|
||||
$this->assertSame('package', $result['granted_by']);
|
||||
}
|
||||
|
||||
public function test_it_grants_access_via_completed_addon(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
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(),
|
||||
]);
|
||||
|
||||
$result = $service->resolveForEvent($event->fresh());
|
||||
|
||||
$this->assertTrue($result['allowed']);
|
||||
$this->assertSame('addon', $result['granted_by']);
|
||||
}
|
||||
|
||||
public function test_it_denies_access_without_feature_or_completed_addon(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
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' => 'pending',
|
||||
]);
|
||||
|
||||
$result = $service->resolveForEvent($event->fresh());
|
||||
|
||||
$this->assertFalse($result['allowed']);
|
||||
$this->assertNull($result['granted_by']);
|
||||
}
|
||||
|
||||
public function test_it_denies_access_when_addon_is_expired_via_metadata(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
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()->subDay()->toIso8601String(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $service->resolveForEvent($event->fresh());
|
||||
|
||||
$this->assertFalse($result['allowed']);
|
||||
$this->assertNull($result['granted_by']);
|
||||
}
|
||||
|
||||
public function test_it_uses_latest_event_package_for_upgrade_and_downgrade(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
|
||||
$premium = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads', 'ai_styling'],
|
||||
]);
|
||||
$starter = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $premium->id,
|
||||
'purchased_price' => $premium->price,
|
||||
'purchased_at' => now()->subDays(5),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
$latest = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $starter->id,
|
||||
'purchased_price' => $starter->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$resultWithoutAddon = $service->resolveForEvent($event->fresh());
|
||||
$this->assertFalse($resultWithoutAddon['allowed']);
|
||||
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $latest->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'ai_styling_unlock',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$resultWithAddon = $service->resolveForEvent($event->fresh());
|
||||
$this->assertTrue($resultWithAddon['allowed']);
|
||||
$this->assertSame('addon', $resultWithAddon['granted_by']);
|
||||
}
|
||||
|
||||
public function test_it_grants_access_via_metadata_feature_even_with_custom_addon_key(): void
|
||||
{
|
||||
$service = app(AiStylingEntitlementService::class);
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
|
||||
$eventPackage = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
EventPackageAddon::query()->create([
|
||||
'event_package_id' => $eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'addon_key' => 'custom_ai_bundle',
|
||||
'quantity' => 1,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => [
|
||||
'entitlements' => [
|
||||
'features' => ['ai_styling'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $service->resolveForEvent($event->fresh());
|
||||
|
||||
$this->assertTrue($result['allowed']);
|
||||
$this->assertSame('addon', $result['granted_by']);
|
||||
}
|
||||
}
|
||||
124
tests/Unit/Services/AiUsageLedgerServiceTest.php
Normal file
124
tests/Unit/Services/AiUsageLedgerServiceTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
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\AiEditing\AiUsageLedgerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiUsageLedgerServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_records_package_included_context_when_feature_is_in_package(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['ai_styling'],
|
||||
]);
|
||||
EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(14),
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'ledger-style-package',
|
||||
'name' => 'Ledger Package',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$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_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'prompt' => 'Package context.',
|
||||
'idempotency_key' => 'ledger-package-context-1',
|
||||
'queued_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$ledger = app(AiUsageLedgerService::class)->recordDebitForRequest($request, 0.01234);
|
||||
|
||||
$this->assertSame('package_included', $ledger->package_context);
|
||||
$this->assertSame('0.01234', $ledger->amount_usd);
|
||||
$this->assertSame('runware', $ledger->metadata['provider'] ?? null);
|
||||
}
|
||||
|
||||
public function test_it_records_addon_context_and_is_idempotent_per_request(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'features' => ['basic_uploads'],
|
||||
]);
|
||||
$eventPackage = EventPackage::query()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => now()->addDays(14),
|
||||
]);
|
||||
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(),
|
||||
]);
|
||||
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'ledger-style-addon',
|
||||
'name' => 'Ledger Addon',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$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_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'prompt' => 'Addon context.',
|
||||
'idempotency_key' => 'ledger-addon-context-1',
|
||||
'queued_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(AiUsageLedgerService::class);
|
||||
$first = $service->recordDebitForRequest($request, 0.01);
|
||||
$second = $service->recordDebitForRequest($request, 0.99);
|
||||
|
||||
$this->assertSame($first->id, $second->id);
|
||||
$this->assertSame('addon_unlock', $first->package_context);
|
||||
$this->assertSame(1, $request->usageLedgers()->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user