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:
293
tests/Feature/Api/PublicApiTest.php
Normal file
293
tests/Feature/Api/PublicApiTest.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user