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>
294 lines
10 KiB
PHP
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');
|
|
}
|
|
}
|