withHeaders(['Accept-Language' => 'es']); } /** * @param array $overrides * @return array */ protected function payload(array $overrides = []): array { return array_merge([ 'name' => 'Ada Lovelace', 'email' => 'ada@example.org', 'message' => 'Tengo unas horas los fines de semana y me gustaría echar una mano.', 'hours_per_week' => 4, 'links' => ['github' => 'https://github.com/ada'], 'consent' => true, ], $overrides); } protected function openCall(): Call { return Call::factory()->create([ 'project_id' => null, 'title' => ['es' => 'Backend', 'en' => 'Backend', 'ca' => 'Backend'], 'slug' => ['es' => 'backend', 'en' => 'backend', 'ca' => 'rerefons'], 'opens_at' => now()->subWeek(), 'closes_at' => now()->addWeek(), ]); } #[Test] public function it_accepts_an_application_to_an_open_call(): void { $call = $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertCreated() ->assertJsonPath('data.status', 'new') ->assertJsonPath('data.duplicate', false) ->assertJsonStructure(['data' => ['id'], 'message']); $application = Application::sole(); $this->assertSame($call->id, $application->call_id); $this->assertSame(ApplicationKind::Contribute, $application->kind); $this->assertSame(ApplicationStatus::New, $application->status); $this->assertSame('ada@example.org', $application->email); $this->assertSame(4, $application->hours_per_week); // Snapshotted, so the record still reads correctly once the call is // renamed or deleted. $this->assertSame('Backend', $application->call_title); } #[Test] public function it_refuses_an_application_to_a_call_that_is_not_open(): void { Call::factory()->closed()->create([ 'project_id' => null, 'slug' => ['es' => 'cerrada'], ]); // Taking somebody's time to write a message for a position that is // already gone is worse than showing them no form at all. $this->postJson('/api/public/v1/calls/cerrada/apply', $this->payload()) ->assertNotFound(); $this->assertSame(0, Application::query()->count()); } #[Test] public function it_requires_the_fields_that_make_a_reply_possible(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', [ 'name' => 'A', 'email' => 'not-an-email', 'message' => 'hola', ])->assertUnprocessable() ->assertJsonValidationErrors(['name', 'email', 'message', 'consent']); } #[Test] public function it_will_not_store_personal_data_without_consent(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload(['consent' => false])) ->assertUnprocessable() ->assertJsonValidationErrors('consent'); $this->assertSame(0, Application::query()->count()); } #[Test] public function a_honeypot_submission_is_filed_as_spam_rather_than_dropped(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ config('site.submissions.honeypot') => 'http://buy-cheap-things.example', ]))->assertCreated() // The bot is told nothing: an obvious rejection is a hint to try // again differently. ->assertJsonPath('data.status', 'new'); // Nothing is thrown away. The honeypot is a heuristic, and a browser // extension can trip it for a real person. $this->assertSame(ApplicationStatus::Spam, Application::sole()->status); } #[Test] public function a_repeat_submission_is_treated_as_the_same_one(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertCreated(); // A double-clicked submit button is not a second person, and an error // would only make the visitor try harder. $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertOk() ->assertJsonPath('data.duplicate', true); $this->assertSame(1, Application::query()->count()); } #[Test] public function the_same_person_may_still_write_about_something_else(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); $this->postJson('/api/public/v1/contact', $this->payload())->assertCreated(); $this->assertSame(2, Application::query()->count()); } #[Test] public function the_general_form_distinguishes_offering_help_from_asking_for_it(): void { $this->postJson('/api/public/v1/contact', $this->payload([ 'kind' => ApplicationKind::Request->value, 'organisation' => 'Asociación Vecinal', 'email' => 'hola@asociacion.example', ]))->assertCreated(); $application = Application::sole(); $this->assertSame(ApplicationKind::Request, $application->kind); $this->assertSame('Asociación Vecinal', $application->organisation); $this->assertNull($application->call_id); } #[Test] public function an_organisation_asking_for_help_has_to_say_who_it_is(): void { $this->postJson('/api/public/v1/contact', $this->payload([ 'kind' => ApplicationKind::Request->value, ]))->assertUnprocessable() ->assertJsonValidationErrors('organisation'); } #[Test] public function the_kind_cannot_be_chosen_when_answering_a_call(): void { $this->openCall(); // Applying to a call is always an offer of time; letting the payload // say otherwise would put replies in the wrong queue. $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ 'kind' => ApplicationKind::Request->value, ]))->assertUnprocessable() ->assertJsonValidationErrors('kind'); } #[Test] public function it_records_the_language_the_visitor_wrote_in(): void { $this->openCall(); // Reached through the Catalan URL of the same call — a slug only // resolves inside its own language. $this->postJson('/api/public/v1/calls/rerefons/apply?lang=ca', $this->payload()) ->assertCreated(); // So the reply goes out in the same language they used. $this->assertSame('ca', Application::sole()->locale); } #[Test] public function it_keeps_a_hash_of_the_address_rather_than_the_address(): void { $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertCreated(); $application = Application::sole(); $this->assertNotNull($application->ip_hash); $this->assertSame(64, strlen($application->ip_hash)); $this->assertStringNotContainsString('127.0.0.1', $application->ip_hash); } #[Test] public function a_confirmation_is_never_cached(): void { $this->openCall(); $response = $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertCreated(); // A shared cache holding one of these would hand it to the next // visitor. $this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); } #[Test] public function it_tells_the_people_who_can_read_the_inbox(): void { Notification::fake(); $this->seed(RolePermissionSeeder::class); $editor = User::factory()->create()->syncRoles('editor')->fresh(); $viewer = User::factory()->create()->syncRoles('viewer')->fresh(); $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); Notification::assertSentTo($editor, ApplicationReceived::class); // A viewer has no business reading strangers' contact details, so // they are not told about them either. Notification::assertNotSentTo($viewer, ApplicationReceived::class); } #[Test] public function spam_does_not_wake_anybody_up(): void { Notification::fake(); $this->seed(RolePermissionSeeder::class); $editor = User::factory()->create()->syncRoles('editor')->fresh(); $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload([ config('site.submissions.honeypot') => 'spam', ]))->assertCreated(); Notification::assertNotSentTo($editor, ApplicationReceived::class); } #[Test] public function submissions_stop_while_the_public_site_is_in_maintenance(): void { $this->openCall(); Maintenance::enable(['es' => 'Volvemos en un rato']); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertServiceUnavailable() ->assertJsonPath('maintenance', true); $this->assertSame(0, Application::query()->count()); } #[Test] public function the_forms_can_be_closed_without_a_deploy(): void { config()->set('site.submissions.enabled', false); $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload()) ->assertServiceUnavailable() ->assertJsonPath('submissions_enabled', false); // And the frontend learns it from /site, so it hides the form instead // of rendering one that would be refused. $this->getJson('/api/public/v1/site') ->assertOk() ->assertJsonPath('data.submissions_enabled', false); } #[Test] public function one_address_cannot_flood_the_inbox(): void { config()->set('site.submissions.rate_limit', 2); // Otherwise the second request would be answered as a duplicate and // never reach the limiter. config()->set('site.submissions.deduplicate_minutes', 0); $this->openCall(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertCreated(); $this->postJson('/api/public/v1/calls/backend/apply', $this->payload())->assertTooManyRequests(); } #[Test] public function the_read_endpoints_stay_read_only(): void { $this->openCall(); // The write routes are explicit; nothing else on the public API // acquired a verb along the way. $this->postJson('/api/public/v1/projects')->assertMethodNotAllowed(); $this->postJson('/api/public/v1/calls')->assertMethodNotAllowed(); } }