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>
270 lines
10 KiB
PHP
270 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\Client;
|
|
use App\Models\Member;
|
|
use App\Models\Project;
|
|
use App\Models\Service;
|
|
use App\Models\Talk;
|
|
use App\Models\Testimonial;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* The pieces a landing page is made of.
|
|
*
|
|
* Four collections, no "home page" payload and no ordering of sections
|
|
* anywhere: what gets composed, and in what order, is the frontend's business.
|
|
*/
|
|
class ShowcaseApiTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->withHeaders(['Accept-Language' => 'es']);
|
|
Queue::fake();
|
|
}
|
|
|
|
#[Test]
|
|
public function it_lists_published_services_in_the_order_they_were_arranged(): void
|
|
{
|
|
Service::factory()->create(['title' => ['es' => 'Segundo'], 'slug' => ['es' => 'segundo'], 'position' => 2]);
|
|
Service::factory()->create(['title' => ['es' => 'Primero'], 'slug' => ['es' => 'primero'], 'position' => 1]);
|
|
Service::factory()->draft()->create(['title' => ['es' => 'Borrador'], 'slug' => ['es' => 'borrador']]);
|
|
|
|
$titles = $this->getJson('/api/public/v1/services')
|
|
->assertOk()
|
|
->json('data.*.title');
|
|
|
|
$this->assertSame(['Primero', 'Segundo'], $titles);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_service_resolves_by_slug_within_its_language(): void
|
|
{
|
|
Service::factory()->create([
|
|
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
|
|
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
|
|
]);
|
|
|
|
$this->getJson('/api/public/v1/services/desarrollo')
|
|
->assertOk()
|
|
->assertJsonPath('data.title', 'Desarrollo');
|
|
|
|
$this->getJson('/api/public/v1/services/desenvolupament?lang=ca')
|
|
->assertOk()
|
|
->assertJsonPath('data.title', 'Desenvolupament');
|
|
|
|
// A Catalan slug must not answer in Spanish, exactly as everywhere
|
|
// else in this API.
|
|
$this->getJson('/api/public/v1/services/desenvolupament')->assertNotFound();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_service_carries_the_work_that_backs_it(): void
|
|
{
|
|
$service = Service::factory()->create([
|
|
'title' => ['es' => 'Desarrollo'], 'slug' => ['es' => 'desarrollo'],
|
|
]);
|
|
|
|
$published = Project::factory()->published()->create([
|
|
'title' => ['es' => 'Web de la asociación'], 'slug' => ['es' => 'web'],
|
|
]);
|
|
$draft = Project::factory()->draft()->create([
|
|
'title' => ['es' => 'A medias'], 'slug' => ['es' => 'a-medias'],
|
|
]);
|
|
|
|
$service->projects()->attach([$published->id, $draft->id]);
|
|
|
|
$data = $this->getJson('/api/public/v1/services/desarrollo')->assertOk()->json('data');
|
|
|
|
// Without real work behind it a service is a promise — but an
|
|
// unpublished project is not something the public may see.
|
|
$this->assertCount(1, $data['projects']);
|
|
$this->assertSame('Web de la asociación', $data['projects'][0]['title']);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_draft_service_is_reachable_only_with_the_preview_secret(): void
|
|
{
|
|
config()->set('site.preview_token', 'secreto');
|
|
|
|
Service::factory()->draft()->create([
|
|
'title' => ['es' => 'Sin publicar'], 'slug' => ['es' => 'sin-publicar'],
|
|
]);
|
|
|
|
$this->getJson('/api/public/v1/services/sin-publicar')->assertNotFound();
|
|
|
|
$this->withHeaders(['X-Preview-Token' => 'secreto'])
|
|
->getJson('/api/public/v1/services/sin-publicar')
|
|
->assertOk();
|
|
}
|
|
|
|
#[Test]
|
|
public function the_wall_of_logos_leaves_out_whoever_asked_to_be_left_out(): void
|
|
{
|
|
$shy = Client::factory()->private()->create(['name' => 'Discreta']);
|
|
$shown = Client::factory()->create(['name' => 'Visible']);
|
|
|
|
Project::factory()->published()->create(['client_id' => $shy->id]);
|
|
Project::factory()->published()->create(['client_id' => $shown->id]);
|
|
|
|
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
|
|
|
|
// Appearing on somebody else's website is their call, not ours.
|
|
$this->assertSame(['Visible'], $names);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_client_with_nothing_published_is_not_a_logo_the_site_can_justify(): void
|
|
{
|
|
$withWork = Client::factory()->create(['name' => 'Con obra']);
|
|
Project::factory()->published()->create(['client_id' => $withWork->id]);
|
|
|
|
$withoutWork = Client::factory()->create(['name' => 'Sin obra']);
|
|
Project::factory()->draft()->create(['client_id' => $withoutWork->id]);
|
|
|
|
$names = $this->getJson('/api/public/v1/clients')->assertOk()->json('data.*.name');
|
|
|
|
$this->assertSame(['Con obra'], $names);
|
|
|
|
// Unless the frontend asks for all of them anyway.
|
|
$all = $this->getJson('/api/public/v1/clients?with_projects=0')->assertOk()->json('data.*.name');
|
|
$this->assertCount(2, $all);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_translated_quote_says_that_it_was_translated(): void
|
|
{
|
|
Testimonial::factory()->create([
|
|
'quote' => ['es' => 'Nos cambiaron la forma de trabajar', 'en' => 'They changed how we work'],
|
|
'original_locale' => 'es',
|
|
'author_name' => 'Marta',
|
|
]);
|
|
|
|
$this->getJson('/api/public/v1/testimonials')
|
|
->assertOk()
|
|
->assertJsonPath('data.0.is_original', true)
|
|
->assertJsonPath('data.0.original_locale', 'es');
|
|
|
|
// Read in English these are our words, not hers, and the frontend is
|
|
// told so it can say "translated from Spanish" rather than pretend.
|
|
$this->withHeaders(['Accept-Language' => 'en'])
|
|
->getJson('/api/public/v1/testimonials')
|
|
->assertOk()
|
|
->assertJsonPath('data.0.is_original', false)
|
|
->assertJsonPath('data.0.quote', 'They changed how we work');
|
|
}
|
|
|
|
#[Test]
|
|
public function testimonials_can_be_asked_for_by_what_they_vouch_for(): void
|
|
{
|
|
$client = Client::factory()->create(['name' => 'Asociación', 'slug' => 'asociacion']);
|
|
$service = Service::factory()->create([
|
|
'title' => ['es' => 'Formación'], 'slug' => ['es' => 'formacion'],
|
|
]);
|
|
|
|
Testimonial::factory()->create(['client_id' => $client->id, 'author_name' => 'De cliente']);
|
|
Testimonial::factory()->create(['service_id' => $service->id, 'author_name' => 'De servicio']);
|
|
Testimonial::factory()->create(['author_name' => 'Suelto']);
|
|
|
|
// Attached to something rather than to "the home page", so a page asks
|
|
// for the ones that belong on it.
|
|
$this->getJson('/api/public/v1/testimonials?client=asociacion')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'data')
|
|
->assertJsonPath('data.0.author.name', 'De cliente');
|
|
|
|
$this->getJson('/api/public/v1/testimonials?service=formacion')
|
|
->assertOk()
|
|
->assertJsonCount(1, 'data')
|
|
->assertJsonPath('data.0.author.name', 'De servicio');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_private_testimonial_never_leaves_the_panel(): void
|
|
{
|
|
Testimonial::factory()->private()->create(['author_name' => 'Sin permiso']);
|
|
|
|
$this->getJson('/api/public/v1/testimonials')
|
|
->assertOk()
|
|
->assertJsonCount(0, 'data');
|
|
}
|
|
|
|
#[Test]
|
|
public function talks_come_back_newest_first_with_their_speaker(): void
|
|
{
|
|
$member = Member::factory()->create(['name' => 'Ada', 'slug' => 'ada']);
|
|
|
|
Talk::factory()->create(['title' => 'La vieja', 'held_at' => now()->subYears(2), 'member_id' => $member->id]);
|
|
Talk::factory()->create(['title' => 'La reciente', 'held_at' => now()->subMonth(), 'member_id' => $member->id]);
|
|
Talk::factory()->create(['title' => 'Oculta', 'is_public' => false]);
|
|
|
|
$data = $this->getJson('/api/public/v1/talks')->assertOk()->json('data');
|
|
|
|
$this->assertSame(['La reciente', 'La vieja'], array_column($data, 'title'));
|
|
$this->assertSame('Ada', $data[0]['speaker']['name']);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_talks_title_is_not_translated(): void
|
|
{
|
|
Talk::factory()->create([
|
|
'title' => 'Taming reactive Node.js',
|
|
'language' => 'en',
|
|
'summary' => ['es' => 'Sobre streams', 'en' => 'About streams'],
|
|
]);
|
|
|
|
// The title is the name of a talk given once, in one language;
|
|
// translating it would invent a talk that never happened.
|
|
$this->getJson('/api/public/v1/talks')
|
|
->assertOk()
|
|
->assertJsonPath('data.0.title', 'Taming reactive Node.js')
|
|
->assertJsonPath('data.0.language', 'en')
|
|
->assertJsonPath('data.0.summary', 'Sobre streams');
|
|
}
|
|
|
|
#[Test]
|
|
public function a_project_now_carries_a_client_rather_than_a_typed_name(): void
|
|
{
|
|
$client = Client::factory()->create(['name' => 'Cruz Roja', 'slug' => 'cruz-roja']);
|
|
|
|
Project::factory()->published()->create([
|
|
'title' => ['es' => 'Su web'], 'slug' => ['es' => 'su-web'],
|
|
'client_id' => $client->id,
|
|
]);
|
|
|
|
$data = $this->getJson('/api/public/v1/projects/su-web')->assertOk()->json('data');
|
|
|
|
$this->assertSame('Cruz Roja', $data['client']['name']);
|
|
$this->assertSame('cruz-roja', $data['client']['slug']);
|
|
|
|
// The old free-text pair is gone, not renamed.
|
|
$this->assertArrayNotHasKey('beneficiary', $data);
|
|
}
|
|
|
|
#[Test]
|
|
public function services_appear_in_the_url_list_the_frontend_builds_its_sitemap_from(): void
|
|
{
|
|
Service::factory()->create([
|
|
'title' => ['es' => 'Desarrollo', 'ca' => 'Desenvolupament'],
|
|
'slug' => ['es' => 'desarrollo', 'ca' => 'desenvolupament'],
|
|
]);
|
|
Service::factory()->draft()->create(['slug' => ['es' => 'borrador']]);
|
|
|
|
$services = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data.services');
|
|
|
|
$this->assertCount(1, $services);
|
|
$this->assertSame('/servicios/desarrollo', $services[0]['alternates']['es']);
|
|
$this->assertSame('/ca/serveis/desenvolupament', $services[0]['alternates']['ca']);
|
|
}
|
|
}
|