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:
292
api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php
Normal file
292
api/tests/Feature/Api/V1/PortalRegistrationFlowTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -46,16 +46,42 @@ function writeStoredActiveEventId(id: string | null): void {
|
||||
else localStorage.removeItem(STORAGE_ACTIVE_EVENT)
|
||||
}
|
||||
|
||||
function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[]): PortalEvent[] {
|
||||
/**
|
||||
* Merge API events with locally stored events.
|
||||
*
|
||||
* When the API call succeeded (`apiSucceeded = true`), the API is the source
|
||||
* of truth: stored events that are NOT confirmed by the API are dropped.
|
||||
* This prevents stale localStorage entries from showing events the user no
|
||||
* longer has access to (e.g. after user_id was cleared).
|
||||
*
|
||||
* When the API call failed (`apiSucceeded = false`), we fall back to stored
|
||||
* events as a best-effort cache.
|
||||
*/
|
||||
function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[], apiSucceeded: boolean): PortalEvent[] {
|
||||
const map = new Map<string, PortalEvent>()
|
||||
for (const e of stored) map.set(e.event_id, { ...e })
|
||||
for (const e of apiEvents) {
|
||||
const prev = map.get(e.event_id)
|
||||
map.set(e.event_id, {
|
||||
...prev,
|
||||
...e,
|
||||
organisation_name: e.organisation_name || prev?.organisation_name || '',
|
||||
})
|
||||
|
||||
if (apiSucceeded) {
|
||||
// API is source of truth — start with API events only
|
||||
for (const e of apiEvents) {
|
||||
const prev = stored.find(s => s.event_id === e.event_id)
|
||||
map.set(e.event_id, {
|
||||
...prev,
|
||||
...e,
|
||||
organisation_name: e.organisation_name || prev?.organisation_name || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
// API failed — merge stored + whatever API returned (likely empty)
|
||||
for (const e of stored) map.set(e.event_id, { ...e })
|
||||
for (const e of apiEvents) {
|
||||
const prev = map.get(e.event_id)
|
||||
map.set(e.event_id, {
|
||||
...prev,
|
||||
...e,
|
||||
organisation_name: e.organisation_name || prev?.organisation_name || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values()).sort((a, b) => b.start_date.localeCompare(a.start_date))
|
||||
@@ -84,7 +110,7 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
* TODO: replace with `portal_events` from GET /auth/me when the API exposes it.
|
||||
*/
|
||||
function savePendingEventFromRegistration(event: PortalEvent): void {
|
||||
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event])
|
||||
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event], false)
|
||||
userEvents.value = merged
|
||||
persistEvents()
|
||||
if (!activeEventId.value || !merged.some(e => e.event_id === activeEventId.value)) {
|
||||
@@ -99,14 +125,16 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
try {
|
||||
const stored = readStoredEvents()
|
||||
let apiEvents: PortalEvent[] = []
|
||||
let apiSucceeded = false
|
||||
try {
|
||||
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
|
||||
apiEvents = data.data.portal_events ?? []
|
||||
apiSucceeded = true
|
||||
}
|
||||
catch {
|
||||
// /auth/me failed — still show locally stored registrations
|
||||
}
|
||||
userEvents.value = mergeEvents(apiEvents, stored)
|
||||
userEvents.value = mergeEvents(apiEvents, stored, apiSucceeded)
|
||||
persistEvents()
|
||||
}
|
||||
catch (e) {
|
||||
@@ -154,7 +182,10 @@ export const usePortalStore = defineStore('portal', () => {
|
||||
)
|
||||
persistEvents()
|
||||
}
|
||||
catch {
|
||||
catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[portal] fetchCurrentPerson failed for event_id:', eid, err)
|
||||
}
|
||||
currentPerson.value = null
|
||||
}
|
||||
finally {
|
||||
|
||||
Reference in New Issue
Block a user