Files
landing-api/tests/Feature/Media/ImageProcessingTest.php
Josep Piguillem 3897113313 Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:55:27 +02:00

244 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Media;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Models\Project;
use App\Services\Media\ImageProcessor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* What an image has to know about itself before a server-rendered page can
* lay it out properly: its size, narrower versions of it, and something to
* paint while it loads.
*/
class ImageProcessingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake(config('media.disk'));
}
/**
* A real encoded image, because the whole point of this code is what it
* does to actual pixels.
*/
protected function putImage(string $path, int $width = 1600, int $height = 900): Media
{
$image = imagecreatetruecolor($width, $height);
imagefill($image, 0, 0, imagecolorallocate($image, 30, 90, 180));
ob_start();
imagejpeg($image, null, 90);
$contents = (string) ob_get_clean();
imagedestroy($image);
Storage::disk(config('media.disk'))->put($path, $contents);
return Media::query()->create(['disk' => config('media.disk'), 'path' => $path]);
}
#[Test]
public function it_records_the_dimensions_a_page_needs_to_reserve_the_box(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$media->refresh();
// Without these the page reflows as each image arrives.
$this->assertSame(1600, $media->width);
$this->assertSame(900, $media->height);
$this->assertSame(1.7778, $media->aspectRatio());
$this->assertSame('image/jpeg', $media->mime);
$this->assertNotNull($media->processed_at);
$this->assertNull($media->error);
}
#[Test]
public function it_derives_narrower_versions_in_a_modern_format(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$media->refresh();
$disk = Storage::disk(config('media.disk'));
$this->assertNotEmpty($media->variants);
foreach ($media->variants as $variant) {
$this->assertTrue($disk->exists($variant['path']), "missing {$variant['path']}");
$this->assertLessThan(1600, $variant['width']);
}
// WebP is the one format every supported PHP can write; AVIF is
// produced too wherever the runtime can encode it.
$this->assertContains('webp', array_column($media->variants, 'format'));
}
#[Test]
public function it_never_scales_an_image_up(): void
{
$media = $this->putImage('projects/small.jpg', 500, 500);
app(ImageProcessor::class)->process($media);
$widths = array_column($media->refresh()->variants, 'width');
// A 500px original must not be republished as a blurry "1600px"
// version; only the widths below it are real.
$this->assertSame([400], array_values(array_unique($widths)));
}
#[Test]
public function it_builds_a_srcset_the_frontend_can_use_verbatim(): void
{
$media = $this->putImage('projects/cover.jpg', 1600, 900);
app(ImageProcessor::class)->process($media);
$srcset = $media->refresh()->srcset();
$this->assertArrayHasKey('webp', $srcset);
$this->assertStringContainsString('400w', $srcset['webp']);
$this->assertStringContainsString('800w', $srcset['webp']);
// Absolute, because the frontend is a different application on a
// different host.
$this->assertStringStartsWith('http', $srcset['webp']);
}
#[Test]
public function it_produces_a_placeholder_small_enough_to_inline(): void
{
$media = $this->putImage('projects/cover.jpg');
app(ImageProcessor::class)->process($media);
$placeholder = $media->refresh()->placeholder;
$this->assertStringStartsWith('data:image/', $placeholder);
// It travels inside every JSON response mentioning the image, so its
// size is part of the contract, not an implementation detail.
$this->assertLessThan(2048, strlen($placeholder));
}
#[Test]
public function reprocessing_replaces_the_previous_versions_rather_than_piling_up(): void
{
$media = $this->putImage('projects/cover.jpg');
app(ImageProcessor::class)->process($media);
$first = $media->refresh()->variants;
app(ImageProcessor::class)->process($media->refresh());
$second = $media->refresh()->variants;
$this->assertCount(count($first), $second);
$disk = Storage::disk(config('media.disk'));
foreach ($second as $variant) {
$this->assertTrue($disk->exists($variant['path']));
}
}
#[Test]
public function a_vector_is_copied_through_untouched(): void
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 120"></svg>';
Storage::disk(config('media.disk'))->put('projects/logo.svg', $svg);
$media = Media::query()->create(['disk' => config('media.disk'), 'path' => 'projects/logo.svg']);
app(ImageProcessor::class)->process($media);
$media->refresh();
// Resampling a vector loses the point of it, but the frontend still
// needs a size to reserve the box — so the viewBox is read.
$this->assertSame(240, $media->width);
$this->assertSame(120, $media->height);
$this->assertSame([], $media->variants);
}
#[Test]
public function a_missing_file_is_recorded_rather_than_thrown(): void
{
$media = Media::query()->create([
'disk' => config('media.disk'),
'path' => 'projects/gone.jpg',
]);
app(ImageProcessor::class)->process($media);
$media->refresh();
// The upload keeps working as a plain image; a broken derivation must
// not take a page down with it.
$this->assertNotNull($media->error);
$this->assertNotNull($media->processed_at);
}
#[Test]
public function saving_an_image_path_registers_it_and_queues_the_work(): void
{
Queue::fake();
$project = Project::factory()->create(['cover_image' => 'projects/new.jpg']);
$media = Media::query()->where('path', 'projects/new.jpg')->sole();
$this->assertSame(config('media.disk'), $media->disk);
Queue::assertPushed(ProcessMedia::class);
// An unrelated save must not queue the same work again.
Queue::fake();
$project->update(['is_featured' => true]);
Queue::assertNotPushed(ProcessMedia::class);
}
#[Test]
public function two_records_pointing_at_one_upload_share_a_single_row(): void
{
Queue::fake();
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
Project::factory()->create(['cover_image' => 'projects/shared.jpg']);
// The file is the identity, so the second one costs no extra
// processing and no second set of derived versions.
$this->assertSame(1, Media::query()->where('path', 'projects/shared.jpg')->count());
Queue::assertPushed(ProcessMedia::class, 1);
}
#[Test]
public function the_command_picks_up_whatever_the_queue_did_not(): void
{
Queue::fake();
$this->putImage('projects/pending.jpg');
$this->assertSame(1, Media::query()->pending()->count());
$this->artisan('media:process')->assertSuccessful();
// The scheduler runs this, so a worker that is down costs a delay
// rather than an image that stays half-done forever.
$this->assertSame(0, Media::query()->pending()->count());
}
}