fix: portal shows stale events from localStorage after user_id unlinked

The portal store merged events from the API with localStorage events
without ever pruning stale entries. When /auth/me returned empty
portal_events (e.g. after a person's user_id was cleared), localStorage
events persisted, causing "registratie niet ophalen" when /portal/me
correctly returned 404.

Now when /auth/me succeeds, API data is the source of truth — stored
events not confirmed by the API are dropped. localStorage fallback is
only used when the API call fails (network error).

Also adds an end-to-end test covering the full register → approve →
portal/me flow including festival hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 02:54:36 +02:00
parent 67ce1e9d9d
commit fcab30e5e8
2 changed files with 335 additions and 12 deletions

View File

@@ -0,0 +1,292 @@
<?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 portal login portal/me
*
* Reproduces: "Portal shows 'registratie niet ophalen' after approved volunteer logs in"
*/
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',
]);
// Create org admin for approval
$this->orgAdmin = User::factory()->create();
$this->orgAdmin->assignRole('super_admin');
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
}
/**
* The golden path: register approve portal/me works.
*/
public function test_full_flow_register_approve_portal_me(): void
{
Mail::fake();
// ── Step 1: Volunteer registers via public form ──
$regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Vrijwilliger',
'last_name' => 'Test',
'email' => 'vrijwilliger@test.nl',
'password' => 'Wachtwoord1',
]);
$regResponse->assertStatus(201);
// Verify person was created with user_id
$person = Person::where('email', 'vrijwilliger@test.nl')->first();
$this->assertNotNull($person, 'Person record should exist');
$this->assertNotNull($person->user_id, 'Person should have user_id after registration');
$this->assertEquals('pending', $person->status);
$user = User::where('email', 'vrijwilliger@test.nl')->first();
$this->assertNotNull($user, 'User account should exist');
$this->assertEquals($user->id, $person->user_id, 'Person.user_id should match created user');
// ── Step 2: Organizer approves the volunteer ─<><E29480><EFBFBD>
Sanctum::actingAs($this->orgAdmin);
$approveResponse = $this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$person->id}/approve"
);
$approveResponse->assertOk();
// Verify person is now approved
$person->refresh();
$this->assertEquals('approved', $person->status);
$this->assertNotNull($person->user_id, 'user_id should still be set after approval');
// ── Step 3: Volunteer logs into portal ──
Sanctum::actingAs($user);
// Step 3a: GET /auth/me should return portal_events
$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');
// Step 3b: GET /portal/me should return person details
$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');
}
/**
* Festival hierarchy: register via sub-event slug portal/me with parent event_id.
*/
public function test_full_flow_with_festival_sub_event(): void
{
Mail::fake();
// Create festival hierarchy
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
]);
$subEvent = Event::factory()->subEvent($festival)->create([
'status' => 'registration_open',
]);
// ── Step 1: Register via sub-event route ──
$regResponse = $this->postJson("/api/v1/events/{$subEvent->id}/volunteer-register", [
'first_name' => 'Festival',
'last_name' => 'Ganger',
'email' => 'festival@test.nl',
'password' => 'Wachtwoord1',
]);
$regResponse->assertStatus(201);
// Person should be linked to parent (festival), not sub-event
$person = Person::where('email', 'festival@test.nl')->first();
$this->assertNotNull($person);
$this->assertEquals($festival->id, $person->event_id, 'Person should be linked to parent event');
$this->assertNotNull($person->user_id);
$user = User::where('email', 'festival@test.nl')->first();
// ── Step 2: Approve ──
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/persons/{$person->id}/approve"
)->assertOk();
// ── Step 3: Portal access ──
Sanctum::actingAs($user);
// portal/me with parent event ID should work
$this->getJson("/api/v1/portal/me?event_id={$festival->id}")
->assertOk()
->assertJsonPath('data.email', 'festival@test.nl');
// portal/me with sub-event ID should also work (controller resolves to parent)
$this->getJson("/api/v1/portal/me?event_id={$subEvent->id}")
->assertOk()
->assertJsonPath('data.email', 'festival@test.nl');
}
/**
* Returning volunteer: existing user registers for new event.
*/
public function test_returning_volunteer_portal_access(): void
{
Mail::fake();
$existingUser = User::factory()->create([
'first_name' => 'Bestaand',
'last_name' => 'Lid',
'email' => 'bestaand@test.nl',
'password' => \Illuminate\Support\Facades\Hash::make('BestaandWw1'),
]);
// Register for event with existing account
$regResponse = $this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Bestaand',
'last_name' => 'Lid',
'email' => 'bestaand@test.nl',
'password' => 'BestaandWw1',
]);
$regResponse->assertStatus(201);
$person = Person::where('email', 'bestaand@test.nl')->first();
$this->assertEquals($existingUser->id, $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();
// Portal access
Sanctum::actingAs($existingUser);
$this->getJson('/api/v1/auth/me')
->assertOk()
->assertJsonCount(1, 'data.portal_events');
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertOk()
->assertJsonPath('data.status', 'approved');
}
/**
* Organizer-created person (no user account) identity match confirmed portal/me.
*/
public function test_organizer_created_person_then_identity_linked(): void
{
// ── Step 1: Organizer creates a person manually ──
$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);
$storeResponse = $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',
]
);
$storeResponse->assertStatus(201);
$person = Person::where('email', 'handmatig@test.nl')->first();
$this->assertNotNull($person);
$this->assertNull($person->user_id, 'Organizer-created person should not have user_id');
// ── Step 2: Portal access should fail (no user_id link) ──
Sanctum::actingAs($user);
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertStatus(404);
// ── Step 3: Confirm identity match → links user_id ──
$match = $person->pendingIdentityMatch;
$this->assertNotNull($match, 'Identity match should have been auto-detected');
Sanctum::actingAs($this->orgAdmin);
$this->postJson(
"/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm"
)->assertOk();
$person->refresh();
$this->assertEquals($user->id, $person->user_id, 'user_id should be set after identity confirm');
// ── Step 4: Portal access should now work ──
Sanctum::actingAs($user);
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertOk()
->assertJsonPath('data.email', 'handmatig@test.nl');
}
/**
* Pending volunteer can see their status via portal (before approval).
*/
public function test_pending_volunteer_can_access_portal_me(): void
{
Mail::fake();
$this->postJson("/api/v1/events/{$this->event->id}/volunteer-register", [
'first_name' => 'Wachtend',
'last_name' => 'Vrijwilliger',
'email' => 'wachtend@test.nl',
'password' => 'Wachtwoord1',
])->assertStatus(201);
$user = User::where('email', 'wachtend@test.nl')->first();
Sanctum::actingAs($user);
// Even without approval, portal/me should work (just shows pending status)
$this->getJson("/api/v1/portal/me?event_id={$this->event->id}")
->assertOk()
->assertJsonPath('data.status', 'pending');
}
}