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