Files
landing-api/tests/Feature/Api/PublicApiTest.php
Josep Piguillem 7e7d2847fe
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Point the public site to each project's TaskStream board
A project with a board now carries its key, its address and the address
of the board's public showcase in the public API, so the website can show
its roadmap, releases and tickets for newcomers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:56 +02:00

321 lines
11 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 App\Services\TaskStream\TaskStreamClient;
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_points_to_the_board_in_taskstream_when_there_is_one(): void
{
config([
'services.taskstream.api_url' => 'https://api.taskstream.test',
'services.taskstream.web_url' => 'https://taskstream.test',
]);
$this->app->forgetInstance(TaskStreamClient::class);
Project::factory()->published()->create([
'slug' => ['es' => 'con-tablero'],
'taskstream_key' => 'WEB',
]);
Project::factory()->published()->create(['slug' => ['es' => 'sin-tablero']]);
$this->getJson('/api/public/v1/projects/con-tablero')
->assertOk()
->assertJsonPath('data.taskstream', [
'key' => 'WEB',
'url' => 'https://taskstream.test/p/WEB',
'web_url' => 'https://taskstream.test',
'showcase_url' => 'https://api.taskstream.test/api/explore/projects/WEB/showcase',
]);
$this->getJson('/api/public/v1/projects/sin-tablero')
->assertJsonPath('data.taskstream', null);
}
#[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');
}
}