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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View 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());
}
}

View 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();
}
}