feat(ai): finalize AI magic edits epic rollout and operations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 22:41:51 +01:00
parent 36bed12ff9
commit 1d2242fb4d
33 changed files with 2621 additions and 18 deletions

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Event;
use App\Models\AiEditingSetting;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
@@ -302,6 +303,7 @@ class EventAiEditControllerTest extends TestCase
$response->assertOk()
->assertJsonPath('data.0.id', $allowed->id)
->assertJsonPath('data.0.version', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('meta.required_feature', 'ai_styling')
->assertJsonPath('meta.allow_custom_prompt', false)
@@ -594,6 +596,129 @@ class EventAiEditControllerTest extends TestCase
$second->assertStatus(429);
}
public function test_guest_submit_endpoint_enforces_event_scope_rate_limit_across_devices(): void
{
config([
'ai-editing.abuse.guest_submit_per_minute' => 10,
'ai-editing.abuse.guest_submit_per_hour' => 100,
'ai-editing.abuse.guest_submit_per_event_per_minute' => 1,
]);
$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-event-rate-limit'])
->getAttribute('plain_token');
$first = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-a'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'First request.',
'idempotency_key' => 'guest-event-rate-limit-1',
]);
$first->assertCreated();
$second = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-b'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Second request.',
'idempotency_key' => 'guest-event-rate-limit-2',
]);
$second->assertStatus(429);
}
public function test_guest_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
{
$event = Event::factory()->create([
'status' => 'published',
]);
$this->attachEntitledEventPackage($event);
$tenantSettings = (array) ($event->tenant->settings ?? []);
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
$event->tenant->update(['settings' => $tenantSettings]);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
AiUsageLedger::query()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.02,
'amount_usd' => 0.02,
'currency' => 'USD',
'recorded_at' => now(),
]);
$token = app(EventJoinTokenService::class)
->createToken($event, ['label' => 'guest-ai-budget-hard-cap'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-budget-cap'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Stylize image.',
'idempotency_key' => 'guest-budget-cap-1',
]);
$response->assertForbidden()
->assertJsonPath('error.code', 'budget_hard_cap_reached')
->assertJsonPath('error.meta.budget.hard_reached', true);
}
public function test_guest_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
{
config([
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
]);
AiEditingSetting::flushCache();
AiEditingSetting::query()->create(array_merge(
AiEditingSetting::defaults(),
['blocked_terms' => ['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-escalation'])
->getAttribute('plain_token');
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-escalation'])
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
'prompt' => 'Create an explicit style image.',
'idempotency_key' => 'guest-escalation-1',
]);
$response->assertCreated()
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
->assertJsonFragment(['abuse_escalation_threshold_reached']);
$requestId = (int) $response->json('data.id');
$editRequest = AiEditRequest::query()->find($requestId);
$this->assertNotNull($editRequest);
$this->assertIsArray($editRequest?->metadata);
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
}
public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
{
$event = Event::factory()->create([