Files
crewli/api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php
bert.hausmans c4a23b6763 feat: passwordless registration — defer account creation to approval
Removes password from the volunteer registration form. Account
creation is now deferred to the approval step:

Backend:
- Registration creates Person without User (user_id=null)
- On approval, system finds or creates User by person.email
- New accounts get a "set password" email with activation link
- Existing accounts get a portal link email
- Added registration_source column to persons (self/organizer)
- Fuzzy name matching skipped for self-registered persons
- person.email is always source of truth for account linking

Frontend:
- Registration form no longer collects password
- Email check shows info alert with login suggestion
- New wachtwoord-instellen.vue page for account activation
- PasswordRequirements.vue component (reused on reset page)
- Success page updated with activation messaging

Tests: 837 passed (all updated for new flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 03:27:47 +02:00

315 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* End-to-end test: public registration → organizer approval → account creation → portal access
*
* New flow: registration creates Person without User. Approval creates/links User.
*/
class PortalRegistrationFlowTest extends TestCase
{
use RefreshDatabase;
private Organisation $organisation;
private Event $event;
private CrowdType $volunteerCrowdType;
private User $orgAdmin;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->organisation = Organisation::factory()->create();
$this->volunteerCrowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
'organisation_id' => $this->organisation->id,
]);
$this->event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
]);
$this->orgAdmin = User::factory()->create();
$this->orgAdmin->assignRole('super_admin');
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
}
/**
* Golden path: register → person without user → approve → user created → portal works.
*/
public function test_full_flow_register_approve_creates_user_and_portal_works(): void
{
Mail::fake();
// ── Step 1: Volunteer registers (no password) ──
$regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Vrijwilliger',
'last_name' => 'Test',
'email' => 'vrijwilliger@test.nl',
]);
$regResponse->assertStatus(201);
$person = Person::where('email', 'vrijwilliger@test.nl')->first();
$this->assertNotNull($person);
$this->assertNull($person->user_id, 'Person should NOT have user_id after registration');
$this->assertEquals('pending', $person->status);
$this->assertEquals('self', $person->registration_source);
// No user account should exist yet
$this->assertDatabaseMissing('users', ['email' => 'vrijwilliger@test.nl']);
// ── Step 2: Organizer approves ──
Sanctum::actingAs($this->orgAdmin);
$approveResponse = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve"
);
$approveResponse->assertOk();
// Approval should have created user account and linked it
$person->refresh();
$this->assertEquals('approved', $person->status);
$this->assertNotNull($person->user_id, 'user_id should be set after approval');
$user = User::where('email', 'vrijwilliger@test.nl')->first();
$this->assertNotNull($user, 'User account should be created on approval');
$this->assertEquals($person->user_id, $user->id);
// ── Step 3: Volunteer accesses portal ──
Sanctum::actingAs($user);
$meResponse = $this->getJson('/api/v1/auth/me');
$meResponse->assertOk();
$meResponse->assertJsonCount(1, 'data.portal_events');
$meResponse->assertJsonPath('data.portal_events.0.event_id', $this->event->id);
$meResponse->assertJsonPath('data.portal_events.0.person_status', 'approved');
$portalMeResponse = $this->getJson("/api/v1/portal/me?event_id={$this->event->id}");
$portalMeResponse->assertOk();
$portalMeResponse->assertJsonPath('data.email', 'vrijwilliger@test.nl');
$portalMeResponse->assertJsonPath('data.status', 'approved');
}
/**
* Approval links existing user by person.email.
*/
public function test_approve_links_existing_user_by_person_email(): void
{
Mail::fake();
// Pre-existing user account
$existingUser = User::factory()->create(['email' => 'bestaand@test.nl']);
// Register with same email
$this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Bestaand',
'last_name' => 'Lid',
'email' => 'bestaand@test.nl',
])->assertStatus(201);
$person = Person::where('email', 'bestaand@test.nl')->first();
$this->assertNull($person->user_id);
// Approve
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve"
)->assertOk();
// Should link to existing user, not create a new one
$person->refresh();
$this->assertEquals($existingUser->id, $person->user_id);
$this->assertEquals(1, User::where('email', 'bestaand@test.nl')->count());
}
/**
* Festival hierarchy: register via sub-event, portal works with both IDs.
*/
public function test_full_flow_with_festival_sub_event(): void
{
Mail::fake();
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
]);
$subEvent = Event::factory()->subEvent($festival)->create([
'status' => 'registration_open',
]);
// Register via sub-event
$this->postJson("/api/v1/events/{$subEvent->id}/volunteer-register", [
'first_name' => 'Festival',
'last_name' => 'Ganger',
'email' => 'festival@test.nl',
])->assertStatus(201);
$person = Person::where('email', 'festival@test.nl')->first();
$this->assertEquals($festival->id, $person->event_id, 'Person should be linked to parent event');
// Approve
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/persons/{$person->id}/approve"
)->assertOk();
$person->refresh();
$user = User::find($person->user_id);
// Portal access
Sanctum::actingAs($user);
$this->getJson("/api/v1/portal/me?event_id={$festival->id}")
->assertOk()
->assertJsonPath('data.email', 'festival@test.nl');
$this->getJson("/api/v1/portal/me?event_id={$subEvent->id}")
->assertOk()
->assertJsonPath('data.email', 'festival@test.nl');
}
/**
* Authenticated registration still links user_id directly.
*/
public function test_authenticated_registration_links_user_directly(): void
{
Mail::fake();
$user = User::factory()->create([
'first_name' => 'Ingelogd',
'last_name' => 'Gebruiker',
'email' => 'ingelogd@test.nl',
]);
$this->organisation->users()->attach($user, ['role' => 'org_member']);
Sanctum::actingAs($user);
$this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [])
->assertStatus(201);
$person = Person::where('email', 'ingelogd@test.nl')->first();
$this->assertEquals($user->id, $person->user_id, 'Authenticated registration should set user_id directly');
}
/**
* Approval skips account creation if person already has user_id (authenticated registration).
*/
public function test_approve_skips_account_creation_if_user_already_linked(): void
{
Mail::fake();
$user = User::factory()->create(['email' => 'al-gelinkt@test.nl']);
$this->organisation->users()->attach($user, ['role' => 'org_member']);
// Authenticated registration sets user_id
Sanctum::actingAs($user);
$this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [])
->assertStatus(201);
$person = Person::where('email', 'al-gelinkt@test.nl')->first();
$this->assertEquals($user->id, $person->user_id);
// Approve — should not create another user
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve"
)->assertOk();
$person->refresh();
$this->assertEquals($user->id, $person->user_id);
$this->assertEquals(1, User::where('email', 'al-gelinkt@test.nl')->count());
}
/**
* Organizer-created person → identity match confirmed → portal/me works.
*/
public function test_organizer_created_person_then_identity_linked(): void
{
$user = User::factory()->create([
'first_name' => 'Handmatig',
'last_name' => 'Toegevoegd',
'email' => 'handmatig@test.nl',
]);
$this->organisation->users()->attach($user, ['role' => 'org_member']);
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons",
[
'crowd_type_id' => $this->volunteerCrowdType->id,
'first_name' => 'Handmatig',
'last_name' => 'Toegevoegd',
'email' => 'handmatig@test.nl',
'status' => 'approved',
]
)->assertStatus(201);
$person = Person::where('email', 'handmatig@test.nl')->first();
$this->assertNull($person->user_id);
// Portal fails without user link
Sanctum::actingAs($user);
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertStatus(404);
// Confirm identity match
$match = $person->pendingIdentityMatch;
$this->assertNotNull($match);
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm"
)->assertOk();
// Portal now works
Sanctum::actingAs($user);
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertOk()
->assertJsonPath('data.email', 'handmatig@test.nl');
}
/**
* Fuzzy name matching is skipped for self-registered persons.
*/
public function test_fuzzy_name_match_skipped_for_self_registered(): void
{
Mail::fake();
// Create a user with similar name but different email
$existingUser = User::factory()->create([
'first_name' => 'Jan',
'last_name' => 'de Vries',
'email' => 'jan.devries@other.nl',
]);
$this->organisation->users()->attach($existingUser, ['role' => 'org_member']);
// Self-register with same name but different email
$this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Jan',
'last_name' => 'de Vries',
'email' => 'jan@voorbeeld.nl',
])->assertStatus(201);
$person = Person::where('email', 'jan@voorbeeld.nl')->first();
// Should NOT have a fuzzy name match (self-registered)
$this->assertNull($person->pendingIdentityMatch);
}
}