Files
landing-api/tests/Feature/Api/PublicApiTest.php
Josep Piguillem 3897113313 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>
2026-08-19 17:55:27 +02:00

294 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Call;
use App\Models\Member;
use App\Models\Project;
use App\Models\Repository;
use App\Models\Technology;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* The contract the public frontend depends on.
*/
class PublicApiTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Symfony's Request::create() injects "Accept-Language: en-us,en;q=0.5"
// by default, and the API honours content negotiation — so without
// this every test would silently be asking for English. Real callers
// send their own header or ?lang=; these tests say so explicitly.
$this->withHeaders(['Accept-Language' => 'es']);
}
#[Test]
public function it_serves_published_projects_without_any_token(): void
{
Project::factory()->published()->create();
Project::factory()->draft()->create();
$response = $this->getJson('/api/public/v1/projects')->assertOk();
$this->assertCount(1, $response->json('data'));
}
#[Test]
public function it_resolves_a_project_by_slug_within_the_active_locale(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Mi proyecto', 'ca' => 'El meu projecte'],
'slug' => ['es' => 'mi-proyecto', 'ca' => 'el-meu-projecte'],
]);
$this->getJson('/api/public/v1/projects/mi-proyecto')
->assertOk()
->assertJsonPath('data.title', 'Mi proyecto');
$this->getJson('/api/public/v1/projects/el-meu-projecte?lang=ca')
->assertOk()
->assertJsonPath('data.title', 'El meu projecte');
// A Catalan slug must not resolve while the Spanish locale is active:
// that would serve one URL under two addresses.
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
}
#[Test]
public function it_returns_hreflang_alternates_for_every_translated_locale(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Uno', 'en' => 'One', 'ca' => 'U'],
'slug' => ['es' => 'uno', 'en' => 'one', 'ca' => 'u'],
]);
$alternates = $this->getJson('/api/public/v1/projects/uno')
->assertOk()
->json('data.alternates');
// The default locale carries no prefix; the others do, each with its
// own translated path segment.
$this->assertSame('/proyectos/uno', $alternates['es']);
$this->assertSame('/en/projects/one', $alternates['en']);
$this->assertSame('/ca/projectes/u', $alternates['ca']);
}
#[Test]
public function a_locale_without_a_translation_gets_no_alternate(): void
{
Project::factory()->published()->create([
'title' => ['es' => 'Solo español'],
'slug' => ['es' => 'solo-espanol'],
]);
$alternates = $this->getJson('/api/public/v1/projects/solo-espanol')
->assertOk()
->json('data.alternates');
// Emitting an alternate here would advertise a URL that 404s.
$this->assertSame(['es'], array_keys($alternates));
}
#[Test]
public function listings_do_not_lazy_load_relations(): void
{
// Guards the N+1 that strict mode caught during development: the
// needs_help flag must come from a count, not from the relation.
$project = Project::factory()->published()->create(['needs_help' => false]);
Call::factory()->create(['project_id' => $project->id]);
$this->getJson('/api/public/v1/projects')
->assertOk()
->assertJsonPath('data.0.needs_help', true);
}
#[Test]
public function unpublished_content_is_hidden_unless_the_preview_secret_is_presented(): void
{
config()->set('site.preview_token', 'the-secret');
Project::factory()->published()->create();
Project::factory()->draft()->create();
$this->assertCount(1, $this->getJson('/api/public/v1/projects')->json('data'));
$this->assertCount(2, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => 'the-secret',
])->json('data'));
$this->assertCount(1, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => 'wrong',
])->json('data'));
}
#[Test]
public function an_unset_preview_secret_never_grants_access(): void
{
config()->set('site.preview_token', null);
Project::factory()->draft()->create();
$this->assertCount(0, $this->getJson('/api/public/v1/projects', [
'X-Preview-Token' => '',
])->json('data'));
}
#[Test]
public function preview_responses_are_never_cached(): void
{
config()->set('site.preview_token', 'the-secret');
$this->getJson('/api/public/v1/projects', ['X-Preview-Token' => 'the-secret'])
->assertHeader('Cache-Control', 'no-store, private');
}
#[Test]
public function public_responses_carry_caching_headers_and_answer_304(): void
{
Project::factory()->published()->create();
$response = $this->getJson('/api/public/v1/projects')->assertOk();
$etag = $response->headers->get('ETag');
$this->assertNotNull($etag);
$this->assertStringContainsString('s-maxage', $response->headers->get('Cache-Control'));
$this->assertStringContainsString('stale-while-revalidate', $response->headers->get('Cache-Control'));
// Language is negotiated, so a shared cache must vary on it.
$this->assertStringContainsString('Accept-Language', $response->headers->get('Vary'));
$this->getJson('/api/public/v1/projects', ['If-None-Match' => $etag])
->assertStatus(304);
}
#[Test]
public function an_open_call_states_the_time_it_asks_for(): void
{
Call::factory()->create([
'slug' => ['es' => 'frontend'],
'hours_per_week' => 4,
'duration_weeks' => 8,
'project_id' => null,
]);
$this->getJson('/api/public/v1/calls/frontend')
->assertOk()
->assertJsonPath('data.hours_per_week', 4)
->assertJsonPath('data.duration_weeks', 8)
->assertJsonPath('data.total_hours', 32);
}
#[Test]
public function a_call_never_leaks_an_unpublished_project(): void
{
$hidden = Project::factory()->draft()->create();
Call::factory()->create(['project_id' => $hidden->id]);
Call::factory()->create(['project_id' => null]);
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
}
#[Test]
public function closed_calls_are_not_listed(): void
{
Call::factory()->create(['project_id' => null]);
Call::factory()->closed()->create(['project_id' => null]);
Call::factory()->create(['project_id' => null, 'closes_at' => now()->subDay()]);
$this->assertCount(1, $this->getJson('/api/public/v1/calls')->json('data'));
}
#[Test]
public function members_hide_the_non_public_and_the_departed(): void
{
Member::factory()->create(['name' => 'Visible']);
Member::factory()->hidden()->create();
Member::factory()->past()->create();
$data = $this->getJson('/api/public/v1/members')->assertOk()->json('data');
$this->assertCount(1, $data);
$this->assertSame('Visible', $data[0]['name']);
// Alumni are still reachable when the frontend asks for them.
$this->assertCount(2, $this->getJson('/api/public/v1/members?include_past=1')->json('data'));
}
#[Test]
public function the_radar_only_lists_placed_technologies(): void
{
Technology::factory()->count(3)->create();
Technology::factory()->count(2)->offRadar()->create();
$this->assertCount(3, $this->getJson('/api/public/v1/radar')->json('data'));
}
#[Test]
public function good_first_issues_exclude_claimed_and_hidden_ones(): void
{
$visible = Repository::factory()->create();
$hidden = Repository::factory()->hidden()->create();
$visible->issues()->create([
'number' => 1, 'title' => 'Free to take',
'html_url' => 'https://example.test/1',
'is_good_first_issue' => true, 'assignee' => null,
]);
$visible->issues()->create([
'number' => 2, 'title' => 'Already claimed',
'html_url' => 'https://example.test/2',
'is_good_first_issue' => true, 'assignee' => 'someone',
]);
$hidden->issues()->create([
'number' => 1, 'title' => 'In a hidden repo',
'html_url' => 'https://example.test/3',
'is_good_first_issue' => true, 'assignee' => null,
]);
$data = $this->getJson('/api/public/v1/good-first-issues')->assertOk()->json('data');
$this->assertCount(1, $data);
$this->assertSame('Free to take', $data[0]['title']);
}
#[Test]
public function the_slugs_endpoint_lists_every_public_url(): void
{
Project::factory()->published()->create(['slug' => ['es' => 'proyecto', 'ca' => 'projecte']]);
Member::factory()->create(['slug' => 'ada']);
$data = $this->getJson('/api/public/v1/slugs')->assertOk()->json('data');
$this->assertSame('/proyectos/proyecto', $data['projects'][0]['alternates']['es']);
$this->assertSame('/ca/projectes/projecte', $data['projects'][0]['alternates']['ca']);
$this->assertSame('/equipo/ada', $data['members'][0]['alternates']['es']);
$this->assertSame('/ca/equip/ada', $data['members'][0]['alternates']['ca']);
}
#[Test]
public function the_site_endpoint_reports_locales_and_headline_numbers(): void
{
Project::factory()->published()->active()->create();
Member::factory()->create();
$this->getJson('/api/public/v1/site')
->assertOk()
->assertJsonPath('data.default_locale', 'es')
->assertJsonPath('data.stats.projects', 1)
->assertJsonPath('data.stats.active_projects', 1)
->assertJsonPath('data.stats.members', 1)
->assertJsonCount(3, 'data.locales');
}
}