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'); } }