seed(RoleSeeder::class); $this->organisation = Organisation::factory()->create(); $this->otherOrganisation = Organisation::factory()->create(); $this->orgAdmin = User::factory()->create(); $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); $this->outsider = User::factory()->create(); $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); $this->otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); } // ---- CRUD Tests ---- public function test_can_list_crowd_lists_for_event(): void { CrowdList::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); // Add persons to one list to verify persons_count $list = $this->event->crowdLists()->first(); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $list->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); $response->assertOk(); $this->assertCount(3, $response->json('data')); // Verify persons_count is present $listData = collect($response->json('data'))->firstWhere('id', $list->id); $this->assertEquals(1, $listData['persons_count']); } public function test_can_create_internal_crowd_list(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ 'crowd_type_id' => $this->crowdType->id, 'name' => 'VIP Gastenlijst', 'type' => 'internal', 'auto_approve' => true, 'max_persons' => 50, ]); $response->assertCreated(); $this->assertEquals('VIP Gastenlijst', $response->json('data.name')); $this->assertEquals('internal', $response->json('data.type')); $this->assertNull($response->json('data.recipient_company_id')); $this->assertTrue($response->json('data.auto_approve')); $this->assertEquals(50, $response->json('data.max_persons')); $this->assertDatabaseHas('crowd_lists', [ 'event_id' => $this->event->id, 'name' => 'VIP Gastenlijst', 'type' => 'internal', ]); } public function test_can_create_external_crowd_list_with_company(): void { $company = Company::factory()->create([ 'organisation_id' => $this->organisation->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ 'crowd_type_id' => $this->crowdType->id, 'name' => 'Catering Medewerkers', 'type' => 'external', 'recipient_company_id' => $company->id, ]); $response->assertCreated(); $this->assertEquals('external', $response->json('data.type')); $this->assertEquals($company->id, $response->json('data.recipient_company_id')); } public function test_store_validates_invalid_type(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists", [ 'crowd_type_id' => $this->crowdType->id, 'name' => 'Test List', 'type' => 'invalid_type', ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['type']); } public function test_can_update_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'name' => 'Oude Naam', 'auto_approve' => false, 'max_persons' => null, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->putJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}", [ 'name' => 'Nieuwe Naam', 'auto_approve' => true, 'max_persons' => 25, ]); $response->assertOk(); $this->assertEquals('Nieuwe Naam', $response->json('data.name')); $this->assertTrue($response->json('data.auto_approve')); $this->assertEquals(25, $response->json('data.max_persons')); } public function test_can_delete_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}"); $response->assertNoContent(); $this->assertDatabaseMissing('crowd_lists', ['id' => $crowdList->id]); } // ---- Person Management Tests ---- public function test_can_add_person_to_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ 'person_id' => $person->id, ]); $response->assertOk(); $this->assertDatabaseHas('crowd_list_persons', [ 'crowd_list_id' => $crowdList->id, 'person_id' => $person->id, 'added_by_user_id' => $this->orgAdmin->id, ]); } public function test_cannot_add_duplicate_person_to_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ 'person_id' => $person->id, ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['person_id']); } public function test_cannot_add_person_beyond_max_persons(): void { $crowdList = CrowdList::factory()->withMaxPersons(2)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $persons = Person::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); // Add two persons (fill the list) foreach ($persons->take(2) as $person) { $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); } Sanctum::actingAs($this->orgAdmin); // Try to add 3rd person $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ 'person_id' => $persons->last()->id, ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['person_id']); } public function test_can_add_person_when_max_persons_is_null(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'max_persons' => null, ]); $persons = Person::factory()->count(5)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); Sanctum::actingAs($this->orgAdmin); // Add all 5 persons — no limit foreach ($persons as $person) { $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ 'person_id' => $person->id, ]); $response->assertOk(); } $this->assertEquals(5, $crowdList->persons()->count()); } public function test_auto_approve_sets_person_status_to_approved(): void { $crowdList = CrowdList::factory()->withAutoApprove()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'pending', ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons", [ 'person_id' => $person->id, ]); $response->assertOk(); $this->assertDatabaseHas('persons', [ 'id' => $person->id, 'status' => 'approved', ]); } public function test_can_remove_person_from_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}/persons/{$person->id}"); $response->assertNoContent(); $this->assertDatabaseMissing('crowd_list_persons', [ 'crowd_list_id' => $crowdList->id, 'person_id' => $person->id, ]); // Person still exists $this->assertDatabaseHas('persons', ['id' => $person->id]); } public function test_crowd_list_resource_includes_persons_count(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $persons = Person::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); foreach ($persons as $person) { $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); } Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); $response->assertOk(); $listData = collect($response->json('data'))->firstWhere('id', $crowdList->id); $this->assertEquals(3, $listData['persons_count']); } // ---- Authorization Tests ---- public function test_cross_org_cannot_access_crowd_lists(): void { Sanctum::actingAs($this->outsider); $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); $response->assertForbidden(); } public function test_unauthenticated_cannot_access_crowd_lists(): void { $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); $response->assertUnauthorized(); } public function test_cross_event_cannot_access_crowd_list(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); // Create a second event in the same org so the user has access to it $eventB = Event::factory()->create(['organisation_id' => $this->organisation->id]); Sanctum::actingAs($this->orgAdmin); // Try to update a list from event A via event B's URL $response = $this->putJson("/api/v1/events/{$eventB->id}/crowd-lists/{$crowdList->id}", [ 'name' => 'Hacked', ]); $response->assertForbidden(); } // ---- Edge Case Tests ---- public function test_deleting_crowd_list_does_not_delete_persons(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); Sanctum::actingAs($this->orgAdmin); $this->deleteJson("/api/v1/events/{$this->event->id}/crowd-lists/{$crowdList->id}") ->assertNoContent(); // Pivot removed (cascade) $this->assertDatabaseMissing('crowd_list_persons', [ 'crowd_list_id' => $crowdList->id, ]); // Person still exists $this->assertDatabaseHas('persons', ['id' => $person->id]); } public function test_persons_count_reflects_current_state(): void { $crowdList = CrowdList::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $persons = Person::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); foreach ($persons as $person) { $crowdList->persons()->attach($person->id, [ 'added_at' => now(), 'added_by_user_id' => $this->orgAdmin->id, ]); } // Remove one $crowdList->persons()->detach($persons->first()->id); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/crowd-lists"); $response->assertOk(); $listData = collect($response->json('data'))->firstWhere('id', $crowdList->id); $this->assertEquals(2, $listData['persons_count']); } }