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:
178
tests/Feature/Api/PublicImagesTest.php
Normal file
178
tests/Feature/Api/PublicImagesTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Member;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* What the frontend receives for an image.
|
||||
*
|
||||
* A path was never enough: rendering one properly server-side needs its size,
|
||||
* narrower versions of it, and a description. All of that is known here, so
|
||||
* all of it is sent.
|
||||
*/
|
||||
class PublicImagesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withHeaders(['Accept-Language' => 'es']);
|
||||
|
||||
// These tests are about the shape of the response, not about GD.
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
protected function project(array $attributes = []): Project
|
||||
{
|
||||
return Project::factory()->published()->create([
|
||||
'title' => ['es' => 'Mi proyecto'],
|
||||
'slug' => ['es' => 'mi-proyecto'],
|
||||
'cover_image' => 'projects/cover.jpg',
|
||||
'cover_image_alt' => ['es' => 'Captura del panel de la asociación'],
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function markProcessed(string $path = 'projects/cover.jpg'): Media
|
||||
{
|
||||
$media = Media::query()->where('path', $path)->sole();
|
||||
|
||||
$media->forceFill([
|
||||
'mime' => 'image/jpeg',
|
||||
'width' => 1600,
|
||||
'height' => 900,
|
||||
'placeholder' => 'data:image/webp;base64,UklGRhoAAABXRUJQ',
|
||||
'variants' => [
|
||||
['format' => 'webp', 'width' => 400, 'path' => 'projects/cover-400.webp', 'size' => 9000],
|
||||
['format' => 'webp', 'width' => 800, 'path' => 'projects/cover-800.webp', 'size' => 30000],
|
||||
],
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_arrives_as_an_object_with_everything_needed_to_render_it(): void
|
||||
{
|
||||
$this->project();
|
||||
$this->markProcessed();
|
||||
|
||||
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.cover_image');
|
||||
|
||||
// Absolute, because the frontend is another application on another
|
||||
// host and a path only this app can resolve is no use to it.
|
||||
$this->assertStringStartsWith('http', $image['url']);
|
||||
|
||||
// The pair that stops the page reflowing as images load.
|
||||
$this->assertSame(1600, $image['width']);
|
||||
$this->assertSame(900, $image['height']);
|
||||
$this->assertSame(1.7778, $image['aspect_ratio']);
|
||||
|
||||
$this->assertSame('Captura del panel de la asociación', $image['alt']);
|
||||
$this->assertStringStartsWith('data:image/', $image['placeholder']);
|
||||
$this->assertStringContainsString('400w', $image['srcset']['webp']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function the_description_follows_the_language_being_read(): void
|
||||
{
|
||||
$this->project([
|
||||
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
|
||||
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
|
||||
'cover_image_alt' => ['es' => 'Captura del panel', 'ca' => 'Captura del tauler'],
|
||||
]);
|
||||
$this->markProcessed();
|
||||
|
||||
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image.alt', 'Captura del tauler');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_with_no_description_says_so_rather_than_omitting_it(): void
|
||||
{
|
||||
$this->project(['cover_image_alt' => null]);
|
||||
$this->markProcessed();
|
||||
|
||||
// `alt=""` is how a page says "this is decorative". A missing key
|
||||
// would leave the frontend guessing.
|
||||
$this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image.alt', '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function an_image_still_renders_before_its_versions_are_ready(): void
|
||||
{
|
||||
$this->project();
|
||||
|
||||
$image = $this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->json('data.cover_image');
|
||||
|
||||
// Degrades into a plain <img> instead of a hole in the page.
|
||||
$this->assertStringStartsWith('http', $image['url']);
|
||||
$this->assertNull($image['width']);
|
||||
$this->assertNull($image['srcset']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_record_without_an_image_returns_null(): void
|
||||
{
|
||||
$this->project(['cover_image' => null, 'cover_image_alt' => null]);
|
||||
|
||||
$this->getJson('/api/public/v1/projects/mi-proyecto')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.cover_image', null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_members_portrait_is_described_by_their_name(): void
|
||||
{
|
||||
Member::factory()->create([
|
||||
'name' => 'Ada Lovelace',
|
||||
'slug' => 'ada',
|
||||
'avatar' => 'members/ada.jpg',
|
||||
]);
|
||||
|
||||
// Nothing to type, so nothing to get wrong.
|
||||
$this->getJson('/api/public/v1/members/ada')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.avatar.alt', 'Ada Lovelace');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_listing_does_not_cost_a_query_per_image(): void
|
||||
{
|
||||
Project::factory()->count(5)->published()->sequence(fn ($sequence) => [
|
||||
'cover_image' => "projects/cover-{$sequence->index}.jpg",
|
||||
])->create();
|
||||
|
||||
DB::enableQueryLog();
|
||||
|
||||
$this->getJson('/api/public/v1/projects')->assertOk();
|
||||
|
||||
$queries = collect(DB::getQueryLog())
|
||||
->filter(fn (array $q) => str_contains($q['query'], 'from "media"'));
|
||||
|
||||
DB::disableQueryLog();
|
||||
|
||||
// One eager load for the whole page, not one lookup per row.
|
||||
$this->assertLessThanOrEqual(1, $queries->count());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user