Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
147
tests/Feature/Enhance/EnhanceAuthorizationTest.php
Normal file
147
tests/Feature/Enhance/EnhanceAuthorizationTest.php
Normal 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');
|
||||
});
|
||||
148
tests/Feature/Enhance/EnhanceCleanupCommandTest.php
Normal file
148
tests/Feature/Enhance/EnhanceCleanupCommandTest.php
Normal 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');
|
||||
});
|
||||
151
tests/Feature/Enhance/EnhanceExternalWorkerProcessorTest.php
Normal file
151
tests/Feature/Enhance/EnhanceExternalWorkerProcessorTest.php
Normal 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();
|
||||
});
|
||||
73
tests/Feature/Enhance/EnhanceHealthCommandTest.php
Normal file
73
tests/Feature/Enhance/EnhanceHealthCommandTest.php
Normal 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);
|
||||
});
|
||||
116
tests/Feature/Enhance/EnhanceModerationActionsTest.php
Normal file
116
tests/Feature/Enhance/EnhanceModerationActionsTest.php
Normal 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');
|
||||
});
|
||||
32
tests/Feature/Enhance/EnhanceModerationTest.php
Normal file
32
tests/Feature/Enhance/EnhanceModerationTest.php
Normal 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();
|
||||
});
|
||||
100
tests/Feature/Enhance/EnhanceRetryHardeningTest.php
Normal file
100
tests/Feature/Enhance/EnhanceRetryHardeningTest.php
Normal 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();
|
||||
});
|
||||
124
tests/Feature/Enhance/EnhanceUploadTest.php
Normal file
124
tests/Feature/Enhance/EnhanceUploadTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user