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->volunteer = User::factory()->create(); $this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']); $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); $this->section = FestivalSection::factory()->create([ 'event_id' => $this->event->id, 'crew_auto_accepts' => false, ]); $this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); $this->person = Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $this->volunteer->id, ]); } private function createOpenShift(array $overrides = []): Shift { return Shift::factory()->open()->create(array_merge([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, ], $overrides)); } // ========================================================================= // Claim workflow // ========================================================================= public function test_volunteer_can_claim_open_shift(): void { $shift = $this->createOpenShift(); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertCreated() ->assertJsonPath('data.status', 'pending_approval') ->assertJsonPath('data.person_id', $this->person->id); $this->assertDatabaseHas('shift_assignments', [ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL->value, ]); } public function test_volunteer_can_claim_auto_approve_shift(): void { $autoSection = FestivalSection::factory()->create([ 'event_id' => $this->event->id, 'crew_auto_accepts' => true, ]); $shift = $this->createOpenShift(['festival_section_id' => $autoSection->id]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$autoSection->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertCreated() ->assertJsonPath('data.status', 'approved') ->assertJsonPath('data.auto_approved', true); } public function test_claim_rejected_when_shift_is_full(): void { $shift = $this->createOpenShift(['slots_open_for_claiming' => 1]); // Fill the claimable slot ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertUnprocessable(); } public function test_claim_rejected_when_shift_status_not_open(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, 'status' => 'draft', ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertUnprocessable(); } public function test_claim_rejected_with_conflicting_assignment(): void { $shift1 = $this->createOpenShift(); $shift2 = $this->createOpenShift(); // Create existing active assignment for the same time slot ShiftAssignment::factory()->create([ 'shift_id' => $shift1->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::APPROVED, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim", ['person_id' => $this->person->id], ); $response->assertUnprocessable(); } public function test_claim_allowed_when_shift_allows_overlap(): void { $shift1 = $this->createOpenShift(); $shift2 = $this->createOpenShift(['allow_overlap' => true]); // Create existing assignment ShiftAssignment::factory()->create([ 'shift_id' => $shift1->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::APPROVED, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim", ['person_id' => $this->person->id], ); $response->assertCreated(); } public function test_claim_rejected_when_person_not_approved(): void { $pendingPerson = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'pending', ]); $shift = $this->createOpenShift(); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $pendingPerson->id], ); $response->assertUnprocessable(); } public function test_unauthenticated_claim_returns_401(): void { $shift = $this->createOpenShift(); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertUnauthorized(); } // ========================================================================= // Assign workflow // ========================================================================= public function test_organizer_can_assign_person_to_shift(): void { $shift = $this->createOpenShift(); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", ['person_id' => $this->person->id], ); $response->assertCreated() ->assertJsonPath('data.status', 'approved') ->assertJsonPath('data.person_id', $this->person->id); $this->assertDatabaseHas('shift_assignments', [ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'status' => ShiftAssignmentStatus::APPROVED->value, 'assigned_by' => $this->orgAdmin->id, 'approved_by' => $this->orgAdmin->id, ]); } public function test_assign_uses_slots_total_not_claiming(): void { // slots_open_for_claiming = 0, but slots_total = 4 $shift = $this->createOpenShift(['slots_open_for_claiming' => 0]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", ['person_id' => $this->person->id], ); $response->assertCreated(); } public function test_assign_allows_overbooking_when_capacity_full(): void { $shift = $this->createOpenShift(['slots_total' => 1]); // Fill the slot ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", ['person_id' => $this->person->id], ); $response->assertCreated(); $this->assertDatabaseHas('shift_assignments', [ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'status' => 'approved', ]); } public function test_assign_rejected_with_conflict(): void { $shift1 = $this->createOpenShift(); $shift2 = $this->createOpenShift(); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift1->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", ['person_id' => $this->person->id], ); $response->assertUnprocessable(); } public function test_non_organizer_cannot_assign(): void { $shift = $this->createOpenShift(); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", ['person_id' => $this->person->id], ); $response->assertForbidden(); } // ========================================================================= // Approve / Reject / Cancel // ========================================================================= public function test_organizer_approves_pending_assignment(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", ); $response->assertOk() ->assertJsonPath('data.status', 'approved'); $this->assertDatabaseHas('shift_assignments', [ 'id' => $assignment->id, 'status' => ShiftAssignmentStatus::APPROVED->value, 'approved_by' => $this->orgAdmin->id, ]); } public function test_organizer_rejects_with_reason(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/reject", ['reason' => 'Onvoldoende ervaring voor deze rol.'], ); $response->assertOk() ->assertJsonPath('data.status', 'rejected') ->assertJsonPath('data.rejection_reason', 'Onvoldoende ervaring voor deze rol.'); } public function test_approve_allows_overbooking_when_shift_is_full(): void { $shift = $this->createOpenShift(['slots_total' => 1]); // Fill the slot ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, ]); // Pending assignment to approve $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", ); $response->assertOk() ->assertJsonPath('data.status', 'approved'); $this->assertDatabaseHas('shift_assignments', [ 'id' => $assignment->id, 'status' => ShiftAssignmentStatus::APPROVED->value, 'approved_by' => $this->orgAdmin->id, ]); } public function test_cannot_approve_already_approved(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", ); $response->assertUnprocessable(); } public function test_volunteer_can_cancel_own_pending_assignment(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", ); $response->assertOk() ->assertJsonPath('data.status', 'cancelled'); } public function test_volunteer_can_cancel_own_approved_assignment(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", ); $response->assertOk() ->assertJsonPath('data.status', 'cancelled'); } public function test_volunteer_cannot_cancel_someone_elses_assignment(): void { $otherPerson = Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $otherPerson->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", ); $response->assertForbidden(); } public function test_organizer_can_cancel_any_assignment(): void { $otherPerson = Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $otherPerson->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel", ); $response->assertOk() ->assertJsonPath('data.status', 'cancelled'); } // ========================================================================= // Bulk approve // ========================================================================= public function test_bulk_approve_multiple_pending(): void { $shift = $this->createOpenShift(); $assignments = collect([1, 2])->map(fn () => ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ])); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/bulk-approve", ['assignment_ids' => $assignments->pluck('id')->toArray()], ); $response->assertOk(); foreach ($assignments as $assignment) { $this->assertDatabaseHas('shift_assignments', [ 'id' => $assignment->id, 'status' => ShiftAssignmentStatus::APPROVED->value, ]); } } public function test_bulk_approve_skips_non_pending(): void { $shift = $this->createOpenShift(); $pending = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); $approved = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/bulk-approve", ['assignment_ids' => [$pending->id, $approved->id]], ); $response->assertOk(); $results = $response->json('data'); $this->assertCount(2, $results); $pendingResult = collect($results)->firstWhere('assignment_id', $pending->id); $approvedResult = collect($results)->firstWhere('assignment_id', $approved->id); $this->assertEquals('approved', $pendingResult['status']); $this->assertEquals('skipped', $approvedResult['status']); } // ========================================================================= // Index / listing // ========================================================================= public function test_index_returns_assignments_for_event(): void { $shift = $this->createOpenShift(); ShiftAssignment::factory()->count(3)->create([ 'shift_id' => $shift->id, 'time_slot_id' => $this->timeSlot->id, 'person_id' => fn () => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments"); $response->assertOk(); $this->assertCount(3, $response->json('data')); } public function test_index_filterable_by_status(): void { $shift = $this->createOpenShift(); ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'time_slot_id' => $this->timeSlot->id, 'person_id' => $this->person->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'time_slot_id' => $this->timeSlot->id, 'person_id' => Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ])->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments?status=pending_approval"); $response->assertOk(); $this->assertCount(1, $response->json('data')); $this->assertEquals('pending_approval', $response->json('data.0.status')); } // ========================================================================= // Multi-tenancy // ========================================================================= public function test_cannot_claim_shift_in_different_organisation(): void { $shift = $this->createOpenShift(); Sanctum::actingAs($this->outsider); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", ['person_id' => $this->person->id], ); $response->assertForbidden(); } public function test_cannot_approve_assignment_in_different_organisation(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL, ]); Sanctum::actingAs($this->outsider); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve", ); $response->assertForbidden(); } // ========================================================================= // Volunteer Availabilities // ========================================================================= public function test_sync_availabilities_for_person(): void { $slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", [ 'availabilities' => [ ['time_slot_id' => $this->timeSlot->id, 'preference_level' => 5], ['time_slot_id' => $slot2->id, 'preference_level' => 2], ], ], ); $response->assertOk(); $this->assertDatabaseCount('volunteer_availabilities', 2); $this->assertDatabaseHas('volunteer_availabilities', [ 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'preference_level' => 5, ]); } public function test_sync_replaces_existing_availabilities(): void { // Create initial availabilities VolunteerAvailability::factory()->create([ 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'preference_level' => 3, ]); $slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", [ 'availabilities' => [ ['time_slot_id' => $slot2->id, 'preference_level' => 4], ], ], ); $response->assertOk(); // Old one removed, only new one exists $this->assertDatabaseCount('volunteer_availabilities', 1); $this->assertDatabaseHas('volunteer_availabilities', [ 'person_id' => $this->person->id, 'time_slot_id' => $slot2->id, 'preference_level' => 4, ]); $this->assertDatabaseMissing('volunteer_availabilities', [ 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); } public function test_sync_rejects_time_slot_from_wrong_event(): void { $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); $otherSlot = TimeSlot::factory()->create(['event_id' => $otherEvent->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", [ 'availabilities' => [ ['time_slot_id' => $otherSlot->id, 'preference_level' => 3], ], ], ); $response->assertUnprocessable(); } public function test_unauthenticated_sync_returns_401(): void { $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync", ['availabilities' => []], ); $response->assertUnauthorized(); } public function test_list_availabilities_for_person(): void { VolunteerAvailability::factory()->create([ 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, 'preference_level' => 4, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/persons/{$this->person->id}/availabilities", ); $response->assertOk(); $this->assertCount(1, $response->json('data')); $this->assertEquals(4, $response->json('data.0.preference_level')); } }