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>
315 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|