Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
});
it('redirects guests away from enhance pages', function (): void {
$this->get(route('enhance.index'))->assertRedirect(route('login'));
$this->get(route('enhance.create'))->assertRedirect(route('login'));
});
it('prevents a user from viewing another users enhance job', function (): void {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($intruder)
->get(route('enhance.show', ['enhanceJob' => $job]))
->assertForbidden();
});
it('shows the selected artwork source on the create page for the owner', function (): void {
$owner = User::factory()->create();
$artwork = Artwork::factory()->for($owner)->create();
$this->actingAs($owner)
->get(route('enhance.create', ['artwork' => $artwork->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Enhance/Create')
->where('selectedArtwork.id', $artwork->id)
->where('selectedArtwork.title', $artwork->title)
->where('selectedArtwork.store_url', route('artworks.enhance.store', ['artwork' => $artwork->id]))
);
});
it('returns a validation error when an artwork source is unavailable', function (): void {
$owner = User::factory()->create();
$artwork = Artwork::factory()->for($owner)->create([
'file_path' => 'uploads/artworks/missing.jpg',
'hash' => null,
'file_ext' => null,
'mime_type' => 'image/jpeg',
]);
$this->from(route('enhance.create', ['artwork' => $artwork->id]))
->actingAs($owner)
->post(route('artworks.enhance.store', ['artwork' => $artwork->id]), [
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create', ['artwork' => $artwork->id]))
->assertSessionHasErrors([
'source' => 'Artwork source file is unavailable for enhance.',
]);
expect(EnhanceJob::query()->count())->toBe(0);
});
it('allows an owner to delete a completed job and its files', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/1/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
]);
$this->actingAs($owner)
->delete(route('enhance.destroy', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.index'));
$deletedJob = EnhanceJob::withTrashed()->find($job->id);
expect($deletedJob)->not->toBeNull();
expect($deletedJob->trashed())->toBeTrue();
Storage::disk('public')->assertMissing('enhance/sources/1/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/1/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/1/preview.webp');
});
it('allows an owner to retry a failed job without deleting the source', function (): void {
Queue::fake();
$owner = User::factory()->create();
$source = UploadedFile::fake()->image('source.png', 300, 300);
Storage::disk('public')->put('enhance/sources/1/source.png', $source->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
'error_message' => 'Example failure',
]);
$this->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->output_path)->toBeNull();
expect($job->preview_path)->toBeNull();
Storage::disk('public')->assertExists('enhance/sources/1/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/1/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/1/preview.webp');
});

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
config()->set('enhance.lifecycle.cleanup_chunk_size', 10);
Storage::fake('public');
});
it('does not delete files during cleanup dry run', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/1/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/1/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/1/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/1/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/1/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/1/preview.webp',
'expires_at' => now()->subMinute(),
]);
$this->artisan('enhance:cleanup --dry-run')->assertSuccessful();
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_COMPLETED);
Storage::disk('public')->assertExists('enhance/sources/1/source.png');
Storage::disk('public')->assertExists('enhance/outputs/1/output.webp');
Storage::disk('public')->assertExists('enhance/previews/1/preview.webp');
});
it('deletes expired completed job files in force mode', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/2/source.png', UploadedFile::fake()->image('source.png')->get());
Storage::disk('public')->put('enhance/outputs/2/output.webp', UploadedFile::fake()->image('output.webp')->get());
Storage::disk('public')->put('enhance/previews/2/preview.webp', UploadedFile::fake()->image('preview.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/2/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/2/output.webp',
'preview_disk' => 'public',
'preview_path' => 'enhance/previews/2/preview.webp',
'expires_at' => now()->subMinute(),
]);
$this->artisan('enhance:cleanup --only=expired --force')->assertSuccessful();
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_EXPIRED);
expect($job->source_path)->toBeNull();
expect($job->output_path)->toBeNull();
expect($job->preview_path)->toBeNull();
expect($job->metadata['cleanup']['reason'])->toBe('expired');
Storage::disk('public')->assertMissing('enhance/sources/2/source.png');
Storage::disk('public')->assertMissing('enhance/outputs/2/output.webp');
Storage::disk('public')->assertMissing('enhance/previews/2/preview.webp');
});
it('does not delete non enhance paths during cleanup', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('uploads/artworks/unsafe.png', 'unsafe');
Storage::disk('public')->put('enhance/outputs/3/output.webp', UploadedFile::fake()->image('output.webp')->get());
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'uploads/artworks/unsafe.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/3/output.webp',
'finished_at' => now()->subDays(10),
]);
$this->artisan('enhance:cleanup --only=failed --days=3 --force')->assertSuccessful();
$job->refresh();
expect($job->source_path)->toBe('uploads/artworks/unsafe.png');
expect($job->output_path)->toBeNull();
Storage::disk('public')->assertExists('uploads/artworks/unsafe.png');
Storage::disk('public')->assertMissing('enhance/outputs/3/output.webp');
});
it('cleans failed and soft deleted enhance files and records cleanup metadata', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/4/source.png', UploadedFile::fake()->image('source.png')->get());
$failed = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/4/source.png',
'finished_at' => now()->subDays(10),
]);
Storage::disk('public')->put('enhance/sources/5/source.png', UploadedFile::fake()->image('source.png')->get());
$deleted = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/5/source.png',
]);
$deleted->delete();
$deleted->forceFill(['deleted_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('enhance:cleanup --only=failed --days=3 --force')->assertSuccessful();
$this->artisan('enhance:cleanup --only=deleted --days=3 --force')->assertSuccessful();
$failed->refresh();
$deleted = EnhanceJob::withTrashed()->findOrFail($deleted->id);
expect($failed->metadata['cleanup']['reason'])->toBe('failed-expired');
expect($deleted->metadata['cleanup']['reason'])->toBe('deleted-grace');
Storage::disk('public')->assertMissing('enhance/sources/4/source.png');
Storage::disk('public')->assertMissing('enhance/sources/5/source.png');
});

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\Enhance\Processors\ExternalWorkerEnhanceProcessor;
use Illuminate\Http\Client\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('app.url', 'http://skinbase.test');
config()->set('enhance.disk', 'public');
config()->set('enhance.external_worker.url', 'http://127.0.0.1:8095');
config()->set('enhance.external_worker.token', 'worker-secret');
config()->set('enhance.external_worker.timeout', 15);
config()->set('enhance.external_worker.max_download_mb', 2);
Storage::fake('public');
});
function makeEnhanceJob(): EnhanceJob {
$user = User::factory()->create();
Storage::disk('public')->put('enhance/sources/10/source.png', UploadedFile::fake()->image('source.png', 40, 40)->get());
return EnhanceJob::query()->create([
'user_id' => $user->id,
'status' => EnhanceJob::STATUS_QUEUED,
'engine' => EnhanceJob::ENGINE_EXTERNAL_WORKER,
'mode' => 'artwork',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/10/source.png',
'input_mime' => 'image/png',
]);
}
it('requires a configured worker url', function (): void {
config()->set('enhance.external_worker.url', '');
app(ExternalWorkerEnhanceProcessor::class)->process(makeEnhanceJob());
})->throws(RuntimeException::class, 'Worker URL is missing.');
it('requires a configured worker token', function (): void {
config()->set('enhance.external_worker.token', '');
app(ExternalWorkerEnhanceProcessor::class)->process(makeEnhanceJob());
})->throws(RuntimeException::class, 'Worker token is missing.');
it('sends bearer token and stores successful worker output', function (): void {
$job = makeEnhanceJob();
$outputBinary = UploadedFile::fake()->image('output.png', 80, 80)->get();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_url' => 'http://127.0.0.1:8095/v1/results/result-output.png',
'width' => 80,
'height' => 80,
'filesize' => strlen($outputBinary),
'mime' => 'image/png',
'metadata' => ['engine' => 'pillow'],
], 200),
'http://127.0.0.1:8095/v1/results/result-output.png' => Http::response($outputBinary, 200, ['Content-Type' => 'image/png']),
'http://127.0.0.1:8095/v1/results/result-output.png*' => Http::response(['success' => true, 'deleted' => true], 200),
]);
$result = app(ExternalWorkerEnhanceProcessor::class)->process($job);
expect($result->width)->toBe(80);
expect($result->height)->toBe(80);
expect($result->mime)->toBe('image/png');
expect($result->metadata['engine'])->toBe('pillow');
Storage::disk('public')->assertExists($result->path);
Http::assertSent(function (Request $request): bool {
if ($request->url() !== 'http://127.0.0.1:8095/v1/upscale') {
return false;
}
$data = $request->data();
$sourceUrl = (string) ($data['source_url'] ?? '');
return $request->hasHeader('Authorization')
&& ($data['mode'] ?? null) === 'artwork'
&& (int) ($data['scale'] ?? 0) === 2
&& (int) ($data['job_id'] ?? 0) > 0
&& ($sourceUrl !== '')
&& (str_contains($sourceUrl, '/internal/enhance/source/') || str_contains($sourceUrl, '/enhance/sources/'));
});
});
it('handles a failed worker response', function (): void {
$job = makeEnhanceJob();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response(['success' => false, 'error' => 'Worker rejected the image.'], 422),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'Worker rejected the image.');
it('handles an invalid json worker response', function (): void {
$job = makeEnhanceJob();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response('not-json', 200, ['Content-Type' => 'text/plain']),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'Worker returned an invalid response.');
it('rejects oversized downloaded output', function (): void {
$job = makeEnhanceJob();
config()->set('enhance.external_worker.max_download_mb', 1);
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_url' => 'http://127.0.0.1:8095/v1/results/too-large.webp',
'mime' => 'image/webp',
], 200),
'http://127.0.0.1:8095/v1/results/too-large.webp' => Http::response(str_repeat('a', (1024 * 1024) + 1), 200, ['Content-Type' => 'image/webp']),
]);
app(ExternalWorkerEnhanceProcessor::class)->process($job);
})->throws(RuntimeException::class, 'The upscaled output exceeded the maximum allowed size.');
it('accepts base64 worker output', function (): void {
$job = makeEnhanceJob();
$outputBinary = UploadedFile::fake()->image('output.png', 90, 60)->get();
Http::fake([
'http://127.0.0.1:8095/v1/upscale' => Http::response([
'success' => true,
'job_id' => $job->id,
'output_base64' => base64_encode($outputBinary),
'mime' => 'image/png',
'metadata' => ['engine' => 'pillow', 'real_ai_upscale' => false],
], 200),
]);
$result = app(ExternalWorkerEnhanceProcessor::class)->process($job);
expect($result->width)->toBe(90);
expect($result->height)->toBe(60);
expect($result->metadata['real_ai_upscale'])->toBeFalse();
});

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
it('renders text health output for enhance', function (): void {
$owner = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'processing_seconds' => 4,
'finished_at' => now(),
]);
$this->artisan('enhance:health')
->expectsOutputToContain('Enhance health')
->expectsOutputToContain('Configured engine')
->assertSuccessful();
});
it('renders json health output with stuck job counts', function (): void {
config()->set('enhance.health.stuck_queued_after_minutes', 60);
config()->set('enhance.health.stuck_processing_after_minutes', 30);
config()->set('enhance.external_worker.url', null);
$owner = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_QUEUED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'queued_at' => now()->subMinutes(61),
]);
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_PROCESSING,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'started_at' => now()->subMinutes(31),
]);
EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'processing_seconds' => 8,
'finished_at' => now(),
]);
Artisan::call('enhance:health', ['--json' => true]);
$payload = json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR);
expect($payload['engine'])->toBe('stub');
expect($payload['queue'])->toBe((string) config('enhance.queue', 'default'));
expect($payload['worker_configured'])->toBeFalse();
expect($payload['health']['stuck_queued'])->toBe(1);
expect($payload['health']['stuck_processing'])->toBe(1);
expect($payload['counts']['completed'])->toBe(1);
expect($payload['today']['average_processing_seconds'])->toBe(8);
});

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
Queue::fake();
});
it('blocks regular users from moderation enhance write actions', function (): void {
$user = User::factory()->create();
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($user)
->post(route('admin.enhance.retry', ['enhanceJob' => $job]))
->assertForbidden();
$this->actingAs($user)
->post(route('admin.enhance.mark-failed', ['enhanceJob' => $job]))
->assertForbidden();
$this->actingAs($user)
->delete(route('admin.enhance.destroy', ['enhanceJob' => $job]))
->assertForbidden();
});
it('allows a moderator to retry a failed enhance job', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/20/source.png', 'source');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/20/source.png',
]);
$this->actingAs($moderator)
->post(route('admin.enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
Queue::assertPushed(ProcessEnhanceJob::class);
});
it('allows a moderator to mark a stuck processing job as failed', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_PROCESSING,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'started_at' => now()->subMinutes(40),
]);
$this->actingAs($moderator)
->post(route('admin.enhance.mark-failed', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_FAILED);
expect($job->error_message)->toBe('Marked as failed by moderator.');
expect($job->metadata['moderation']['marked_failed_by'])->toBe($moderator->id);
});
it('uses safe cleanup when moderation deletes a job', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
Storage::disk('public')->put('uploads/artworks/unsafe.png', 'unsafe');
Storage::disk('public')->put('enhance/outputs/21/output.webp', 'output');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'uploads/artworks/unsafe.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/21/output.webp',
]);
$this->actingAs($moderator)
->delete(route('admin.enhance.destroy', ['enhanceJob' => $job]))
->assertRedirect(route('admin.enhance.index'));
expect(EnhanceJob::withTrashed()->find($job->id)?->trashed())->toBeTrue();
Storage::disk('public')->assertExists('uploads/artworks/unsafe.png');
Storage::disk('public')->assertMissing('enhance/outputs/21/output.webp');
});

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\EnhanceJob;
use App\Models\User;
it('allows moderators to browse enhance jobs in moderation', function (): void {
$moderator = User::factory()->create(['role' => 'moderator']);
$creator = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $creator->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$this->actingAs($moderator)
->get(route('admin.enhance.index'))
->assertOk()
->assertSee((string) $job->id);
});
it('blocks regular users from the moderation enhance surface', function (): void {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.enhance.index'))
->assertForbidden();
});

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
Queue::fake();
});
it('increments retry metadata and clears failure fields on retry', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/10/source.png', 'source');
Storage::disk('public')->put('enhance/outputs/10/output.webp', 'output');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/10/source.png',
'output_disk' => 'public',
'output_path' => 'enhance/outputs/10/output.webp',
'error_message' => 'Example failure',
'started_at' => now()->subMinute(),
'finished_at' => now()->subSeconds(5),
]);
$this->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->error_message)->toBeNull();
expect($job->started_at)->toBeNull();
expect($job->finished_at)->toBeNull();
expect($job->metadata['retry_count'])->toBe(1);
expect($job->metadata['last_retried_at'])->not->toBeNull();
Queue::assertPushed(ProcessEnhanceJob::class);
});
it('does not dispatch retry when the source file is missing', function (): void {
$owner = User::factory()->create();
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_FAILED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/11/missing.png',
'error_message' => 'Example failure',
]);
$this->from(route('enhance.show', ['enhanceJob' => $job]))
->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertRedirect(route('enhance.show', ['enhanceJob' => $job]))
->assertSessionHasErrors([
'job' => 'This enhance job can no longer be retried because the original source file was cleaned up.',
]);
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_FAILED);
Queue::assertNothingPushed();
});
it('rejects retrying non failed jobs', function (): void {
$owner = User::factory()->create();
Storage::disk('public')->put('enhance/sources/12/source.png', 'source');
$job = EnhanceJob::query()->create([
'user_id' => $owner->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
'source_disk' => 'public',
'source_path' => 'enhance/sources/12/source.png',
]);
$this->from(route('enhance.show', ['enhanceJob' => $job]))
->actingAs($owner)
->post(route('enhance.retry', ['enhanceJob' => $job]))
->assertForbidden();
Queue::assertNothingPushed();
});

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Jobs\Enhance\ProcessEnhanceJob;
use App\Models\EnhanceJob;
use App\Models\User;
use App\Services\Enhance\EnhanceProcessorFactory;
use App\Services\Enhance\EnhanceService;
use App\Services\Enhance\EnhanceStorageService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config()->set('enhance.disk', 'public');
Storage::fake('public');
});
it('allows an authenticated user to create an enhance job from an upload', function (): void {
Queue::fake();
$user = User::factory()->create();
$file = UploadedFile::fake()->image('wallpaper.png', 1200, 800)->size(1024);
$response = $this->actingAs($user)->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
]);
$job = EnhanceJob::query()->first();
$response->assertRedirect(route('enhance.show', ['enhanceJob' => $job]));
expect($job)->not->toBeNull();
expect($job->status)->toBe(EnhanceJob::STATUS_QUEUED);
expect($job->source_path)->not->toBe('');
Storage::disk('public')->assertExists($job->source_path);
Queue::assertPushed(ProcessEnhanceJob::class, function (ProcessEnhanceJob $queuedJob) use ($job): bool {
return true;
});
});
it('rejects unsupported mime types', function (): void {
$user = User::factory()->create();
$file = UploadedFile::fake()->create('vector.svg', 10, 'image/svg+xml');
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors('image');
});
it('rejects invalid scale and mode values', function (): void {
$user = User::factory()->create();
$file = UploadedFile::fake()->image('bad.png', 1200, 800)->size(512);
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 3,
'mode' => 'broken',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors(['scale', 'mode']);
});
it('enforces the daily enhance limit', function (): void {
config()->set('enhance.daily_limit', 1);
$user = User::factory()->create();
EnhanceJob::query()->create([
'user_id' => $user->id,
'status' => EnhanceJob::STATUS_COMPLETED,
'engine' => EnhanceJob::ENGINE_STUB,
'mode' => 'standard',
'scale' => 2,
]);
$file = UploadedFile::fake()->image('wallpaper.png', 1200, 800)->size(512);
$this->actingAs($user)
->from(route('enhance.create'))
->post(route('enhance.store'), [
'image' => $file,
'scale' => 2,
'mode' => 'standard',
])
->assertRedirect(route('enhance.create'))
->assertSessionHasErrors('image');
});
it('completes a queued job with the stub processor', function (): void {
Queue::fake();
$user = User::factory()->create();
$file = UploadedFile::fake()->image('art.png', 640, 480)->size(256);
$job = app(EnhanceService::class)->createFromUpload($user, $file, [
'scale' => 2,
'mode' => 'standard',
'engine' => 'stub',
]);
$processorJob = new ProcessEnhanceJob($job->id);
$processorJob->handle(app(EnhanceProcessorFactory::class), app(EnhanceStorageService::class));
$job->refresh();
expect($job->status)->toBe(EnhanceJob::STATUS_COMPLETED);
expect($job->output_path)->not->toBeNull();
expect($job->preview_path)->not->toBeNull();
Storage::disk('public')->assertExists($job->output_path);
Storage::disk('public')->assertExists($job->preview_path);
});