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>
This commit is contained in:
243
tests/Feature/Media/ImageProcessingTest.php
Normal file
243
tests/Feature/Media/ImageProcessingTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
153
tests/Feature/Media/ProjectGalleryTest.php
Normal file
153
tests/Feature/Media/ProjectGalleryTest.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Media;
|
||||
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Models\ImageAttachment;
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* More than one image on a project: the screenshots and the before-and-after
|
||||
* that make a write-up worth reading.
|
||||
*/
|
||||
class ProjectGalleryTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
protected function projectWithGallery(): Project
|
||||
{
|
||||
$project = Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
]);
|
||||
|
||||
$project->images()->createMany([
|
||||
[
|
||||
'path' => 'projects/gallery/after.jpg',
|
||||
'alt' => ['es' => 'El panel nuevo'],
|
||||
'caption' => ['es' => 'Después'],
|
||||
'position' => 2,
|
||||
],
|
||||
[
|
||||
'path' => 'projects/gallery/before.jpg',
|
||||
'alt' => ['es' => 'La hoja de cálculo que usaban'],
|
||||
'caption' => ['es' => 'Antes'],
|
||||
'position' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_gallery_comes_back_in_the_order_it_was_arranged(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
$gallery = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.gallery');
|
||||
|
||||
// An unordered gallery is a different gallery every time it is read,
|
||||
// and "before" after "after" tells the wrong story.
|
||||
$this->assertSame(['Antes', 'Después'], array_column($gallery, 'caption'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function each_image_carries_everything_needed_to_render_it(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
Media::query()->where('path', 'projects/gallery/before.jpg')->sole()->forceFill([
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'variants' => [
|
||||
['format' => 'webp', 'width' => 400, 'path' => 'projects/gallery/before-400.webp', 'size' => 8000],
|
||||
],
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$first = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.gallery.0');
|
||||
|
||||
$this->assertStringStartsWith('http', $first['url']);
|
||||
$this->assertSame(1200, $first['width']);
|
||||
$this->assertSame('La hoja de cálculo que usaban', $first['alt']);
|
||||
$this->assertStringContainsString('400w', $first['srcset']['webp']);
|
||||
|
||||
// A caption is not a description: one is shown to everyone, the other
|
||||
// is read by whoever cannot see the image.
|
||||
$this->assertSame('Antes', $first['caption']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_listing_does_not_carry_every_screenshot_of_every_project(): void
|
||||
{
|
||||
$this->projectWithGallery();
|
||||
|
||||
$project = $this->getJson('/api/public/v1/projects')
|
||||
->assertOk()
|
||||
->json('data.0');
|
||||
|
||||
$this->assertArrayNotHasKey('gallery', $project);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function adding_a_gallery_image_registers_it_like_any_other_upload(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$project->images()->create(['path' => 'projects/gallery/new.jpg']);
|
||||
|
||||
// Nothing special about a gallery image: same media row, same derived
|
||||
// versions, same queue.
|
||||
$this->assertSame(1, Media::query()->where('path', 'projects/gallery/new.jpg')->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function erasing_a_project_for_good_takes_its_gallery_with_it(): void
|
||||
{
|
||||
$project = $this->projectWithGallery();
|
||||
|
||||
$project->delete();
|
||||
|
||||
// A soft delete keeps it — that is the point of a soft delete.
|
||||
$this->assertSame(2, ImageAttachment::query()->count());
|
||||
|
||||
$project->forceDelete();
|
||||
|
||||
// A morph relation has no foreign key to cascade for it, so this is
|
||||
// handled explicitly and worth pinning down.
|
||||
$this->assertSame(0, ImageAttachment::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_gallery_editor_renders(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$project = $this->projectWithGallery();
|
||||
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
|
||||
$this->get(ProjectResource::getUrl('edit', ['record' => $project]))
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user