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>
244 lines
7.8 KiB
PHP
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());
|
|
}
|
|
}
|