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

@@ -0,0 +1,172 @@
<?php
namespace Tests\Feature\Console;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
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 AiEditsPruneCommandTest extends TestCase
{
use RefreshDatabase;
public function test_command_prunes_stale_requests_and_ledgers(): void
{
config([
'ai-editing.retention.request_days' => 90,
'ai-editing.retention.usage_ledger_days' => 365,
]);
[$oldRequest, $recentRequest] = $this->createRequestsForPruning();
[$oldLedger, $recentLedger] = $this->createLedgerEntriesForPruning($oldRequest, $recentRequest);
$this->artisan('ai-edits:prune')
->expectsOutputToContain('AI prune candidates')
->expectsOutputToContain('Pruned AI data')
->assertExitCode(0);
$this->assertDatabaseMissing('ai_edit_requests', ['id' => $oldRequest->id]);
$this->assertDatabaseMissing('ai_provider_runs', ['request_id' => $oldRequest->id]);
$this->assertDatabaseMissing('ai_edit_outputs', ['request_id' => $oldRequest->id]);
$this->assertDatabaseHas('ai_edit_requests', ['id' => $recentRequest->id]);
$this->assertDatabaseMissing('ai_usage_ledgers', ['id' => $oldLedger->id]);
$this->assertNotNull($recentLedger);
if ($recentLedger) {
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $recentLedger->id]);
}
}
public function test_command_pretend_mode_does_not_delete_records(): void
{
config([
'ai-editing.retention.request_days' => 90,
'ai-editing.retention.usage_ledger_days' => 365,
]);
[$oldRequest] = $this->createRequestsForPruning();
[$oldLedger] = $this->createLedgerEntriesForPruning($oldRequest, null);
$this->artisan('ai-edits:prune', ['--pretend' => true])
->expectsOutputToContain('AI prune candidates')
->expectsOutput('Pretend mode enabled. No records were deleted.')
->assertExitCode(0);
$this->assertDatabaseHas('ai_edit_requests', ['id' => $oldRequest->id]);
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $oldLedger->id]);
}
/**
* @return array{0: AiEditRequest, 1: AiEditRequest}
*/
private function createRequestsForPruning(): array
{
$event = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
]);
$style = AiStyle::query()->create([
'key' => 'prune-style',
'name' => 'Prune Style',
'provider' => 'runware',
'provider_model' => 'runware-default',
'requires_source_image' => true,
'is_active' => true,
]);
$oldRequest = 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' => 'Old request',
'idempotency_key' => 'old-prune-request',
'queued_at' => now()->subDays(121),
'started_at' => now()->subDays(120),
'completed_at' => now()->subDays(120),
'expires_at' => now()->subDays(30),
]);
AiProviderRun::query()->create([
'request_id' => $oldRequest->id,
'provider' => 'runware',
'attempt' => 1,
'provider_task_id' => 'old-task-id',
'status' => AiProviderRun::STATUS_SUCCEEDED,
'started_at' => now()->subDays(120),
'finished_at' => now()->subDays(120),
]);
AiEditOutput::query()->create([
'request_id' => $oldRequest->id,
'provider_asset_id' => 'old-asset-id',
'provider_url' => 'https://cdn.example.invalid/old.jpg',
'safety_state' => 'passed',
'generated_at' => now()->subDays(120),
]);
$recentRequest = 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' => 'Recent request',
'idempotency_key' => 'recent-prune-request',
'queued_at' => now()->subDays(11),
'started_at' => now()->subDays(10),
'completed_at' => now()->subDays(10),
]);
return [$oldRequest, $recentRequest];
}
/**
* @return array{0: AiUsageLedger, 1: ?AiUsageLedger}
*/
private function createLedgerEntriesForPruning(AiEditRequest $oldRequest, ?AiEditRequest $recentRequest): array
{
$oldLedger = AiUsageLedger::query()->create([
'tenant_id' => $oldRequest->tenant_id,
'event_id' => $oldRequest->event_id,
'request_id' => $oldRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.01,
'amount_usd' => 0.01,
'currency' => 'USD',
'recorded_at' => now()->subDays(400),
]);
if (! $recentRequest) {
return [$oldLedger, null];
}
$recentLedger = AiUsageLedger::query()->create([
'tenant_id' => $recentRequest->tenant_id,
'event_id' => $recentRequest->event_id,
'request_id' => $recentRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => 0.01,
'amount_usd' => 0.01,
'currency' => 'USD',
'recorded_at' => now()->subDays(30),
]);
return [$oldLedger, $recentLedger];
}
}