seed(RoleSeeder::class); $this->organisation = Organisation::factory()->create(); $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, 'person_type' => 'VOLUNTEER', 'date' => now()->addMonth(), ]); $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)); } // ========================================================================= // Available shifts // ========================================================================= public function test_available_shifts_returns_grouped_by_date_and_time_slot(): void { $shift = $this->createOpenShift(['title' => 'Tapper']); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertOk() ->assertJsonStructure([ 'data' => [ '*' => [ 'date', 'date_label', 'time_slots' => [ '*' => [ 'time_slot_id', 'name', 'start_time', 'end_time', 'shifts' => [ '*' => [ 'id', 'title', 'section_name', 'section_icon', 'location_name', 'slots_total', 'slots_open_for_claiming', 'slots_claimed', 'slots_available', 'has_conflict', 'conflict_reason', 'report_time', 'description', ], ], ], ], ], ], ]); $this->assertCount(1, $response->json('data')); $this->assertEquals('Tapper', $response->json('data.0.time_slots.0.shifts.0.title')); } public function test_available_shifts_only_shows_volunteer_time_slots(): void { // Create a CREW time slot with a shift — should not appear $crewSlot = TimeSlot::factory()->create([ 'event_id' => $this->event->id, 'person_type' => 'CREW', 'date' => now()->addMonth(), ]); $this->createOpenShift(['time_slot_id' => $crewSlot->id]); // Create a VOLUNTEER shift — should appear $this->createOpenShift(['title' => 'Volunteer Shift']); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertOk(); $allShifts = collect($response->json('data')) ->flatMap(fn ($day) => collect($day['time_slots'])) ->flatMap(fn ($ts) => $ts['shifts']); $this->assertCount(1, $allShifts); $this->assertEquals('Volunteer Shift', $allShifts->first()['title']); } public function test_available_shifts_excludes_full_shifts(): 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->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertOk(); $allShifts = collect($response->json('data')) ->flatMap(fn ($day) => collect($day['time_slots'])) ->flatMap(fn ($ts) => $ts['shifts']); $this->assertCount(0, $allShifts); } public function test_available_shifts_marks_conflicting_shifts(): void { $shift = $this->createOpenShift(); // Create existing assignment on same time slot ShiftAssignment::factory()->approved()->create([ 'shift_id' => Shift::factory()->open()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, ])->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertOk(); $shiftData = $response->json('data.0.time_slots.0.shifts.0'); $this->assertTrue($shiftData['has_conflict']); $this->assertNotNull($shiftData['conflict_reason']); } public function test_available_shifts_pending_person_gets_403(): void { $pendingUser = User::factory()->create(); $this->organisation->users()->attach($pendingUser, ['role' => 'org_member']); Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $pendingUser->id, 'status' => 'pending', ]); Sanctum::actingAs($pendingUser); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertForbidden(); } public function test_available_shifts_unauthenticated_returns_401(): void { $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertUnauthorized(); } public function test_available_shifts_only_shows_open_status_shifts(): void { // Draft shift — should not appear 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', ]); // Open shift — should appear $this->createOpenShift(['title' => 'Open Shift']); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/available-shifts"); $response->assertOk(); $allShifts = collect($response->json('data')) ->flatMap(fn ($day) => collect($day['time_slots'])) ->flatMap(fn ($ts) => $ts['shifts']); $this->assertCount(1, $allShifts); $this->assertEquals('Open Shift', $allShifts->first()['title']); } public function test_available_shifts_includes_sub_event_shifts_for_festival(): void { // Create a festival parent with a sub-event $festival = Event::factory()->festival()->create([ 'organisation_id' => $this->organisation->id, ]); $subEvent = Event::factory()->subEvent($festival)->create(); // Person is registered at the festival level $person = Person::factory()->approved()->create([ 'event_id' => $festival->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $this->volunteer->id, ]); // Shift lives on the sub-event $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]); $subSlot = TimeSlot::factory()->create([ 'event_id' => $subEvent->id, 'person_type' => 'VOLUNTEER', 'date' => now()->addMonth(), ]); $shift = Shift::factory()->open()->create([ 'festival_section_id' => $subSection->id, 'time_slot_id' => $subSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, 'title' => 'Sub-event Shift', ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$festival->id}/available-shifts"); $response->assertOk(); $allShifts = collect($response->json('data')) ->flatMap(fn ($day) => collect($day['time_slots'])) ->flatMap(fn ($ts) => $ts['shifts']); $this->assertCount(1, $allShifts); $this->assertEquals('Sub-event Shift', $allShifts->first()['title']); } public function test_my_shifts_includes_sub_event_assignments_for_festival(): void { $festival = Event::factory()->festival()->create([ 'organisation_id' => $this->organisation->id, ]); $subEvent = Event::factory()->subEvent($festival)->create(); $person = Person::factory()->approved()->create([ 'event_id' => $festival->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $this->volunteer->id, ]); $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]); $subSlot = TimeSlot::factory()->create([ 'event_id' => $subEvent->id, 'person_type' => 'VOLUNTEER', 'date' => now()->addMonth(), ]); $shift = Shift::factory()->open()->create([ 'festival_section_id' => $subSection->id, 'time_slot_id' => $subSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $person->id, 'time_slot_id' => $subSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$festival->id}/my-shifts"); $response->assertOk(); $this->assertCount(1, $response->json('data.upcoming')); } // ========================================================================= // My shifts // ========================================================================= public function test_my_shifts_returns_sections(): void { $futureSlot = $this->timeSlot; // Already future $pastSlot = TimeSlot::factory()->create([ 'event_id' => $this->event->id, 'person_type' => 'VOLUNTEER', 'date' => now()->subMonth(), ]); $futureShift = $this->createOpenShift(); $pastShift = $this->createOpenShift(['time_slot_id' => $pastSlot->id]); // Upcoming approved ShiftAssignment::factory()->approved()->create([ 'shift_id' => $futureShift->id, 'person_id' => $this->person->id, 'time_slot_id' => $futureSlot->id, ]); // Past approved ShiftAssignment::factory()->approved()->create([ 'shift_id' => $pastShift->id, 'person_id' => $this->person->id, 'time_slot_id' => $pastSlot->id, ]); // Cancelled ShiftAssignment::factory()->create([ 'shift_id' => Shift::factory()->open()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $futureSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, ])->id, 'person_id' => $this->person->id, 'time_slot_id' => $futureSlot->id, 'status' => ShiftAssignmentStatus::CANCELLED, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/my-shifts"); $response->assertOk() ->assertJsonStructure([ 'data' => [ 'upcoming', 'past', 'cancelled', ], ]); $this->assertCount(1, $response->json('data.upcoming')); $this->assertCount(1, $response->json('data.past')); $this->assertCount(1, $response->json('data.cancelled')); } public function test_my_shifts_can_cancel_future_pending(): void { $shift = $this->createOpenShift(); 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->getJson("/api/v1/portal/events/{$this->event->id}/my-shifts"); $response->assertOk(); $this->assertTrue($response->json('data.upcoming.0.can_cancel')); } public function test_my_shifts_cannot_cancel_past_shifts(): void { $pastSlot = TimeSlot::factory()->create([ 'event_id' => $this->event->id, 'person_type' => 'VOLUNTEER', 'date' => now()->subMonth(), ]); $pastShift = $this->createOpenShift(['time_slot_id' => $pastSlot->id]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $pastShift->id, 'person_id' => $this->person->id, 'time_slot_id' => $pastSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/my-shifts"); $response->assertOk(); $this->assertFalse($response->json('data.past.0.can_cancel')); } public function test_my_shifts_only_returns_own_assignments(): void { $shift = $this->createOpenShift(); // Other person's assignment $otherPerson = Person::factory()->approved()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $otherPerson->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/my-shifts"); $response->assertOk(); $this->assertCount(0, $response->json('data.upcoming')); $this->assertCount(0, $response->json('data.past')); $this->assertCount(0, $response->json('data.cancelled')); } public function test_my_shifts_unauthenticated_returns_401(): void { $response = $this->getJson("/api/v1/portal/events/{$this->event->id}/my-shifts"); $response->assertUnauthorized(); } // ========================================================================= // Claim // ========================================================================= public function test_successful_claim_returns_assignment(): void { $shift = $this->createOpenShift(); Sanctum::actingAs($this->volunteer); $response = $this->postJson("/api/v1/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertOk() ->assertJsonPath('data.status', 'pending_approval') ->assertJsonPath('data.message', 'Je claim is ingediend en wacht op goedkeuring.'); $this->assertDatabaseHas('shift_assignments', [ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'status' => ShiftAssignmentStatus::PENDING_APPROVAL->value, ]); } public function test_auto_approve_claim_returns_approved_status(): 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/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertOk() ->assertJsonPath('data.status', 'approved') ->assertJsonPath('data.message', 'Je bent ingepland!'); } public function test_claim_full_shift_returns_422(): 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/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertUnprocessable() ->assertJsonFragment(['message' => 'Deze dienst is helaas al vol.']); } public function test_claim_conflict_returns_422(): 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->volunteer); $response = $this->postJson("/api/v1/portal/events/{$this->event->id}/shifts/{$shift2->id}/claim"); $response->assertUnprocessable() ->assertJsonFragment(['message' => 'Je hebt al een dienst op dit tijdslot.']); } public function test_claim_pending_person_returns_403(): void { $pendingUser = User::factory()->create(); $this->organisation->users()->attach($pendingUser, ['role' => 'org_member']); Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $pendingUser->id, 'status' => 'pending', ]); $shift = $this->createOpenShift(); Sanctum::actingAs($pendingUser); $response = $this->postJson("/api/v1/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertForbidden(); } public function test_claim_draft_shift_returns_422(): 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/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertUnprocessable(); } public function test_claim_unauthenticated_returns_401(): void { $shift = $this->createOpenShift(); $response = $this->postJson("/api/v1/portal/events/{$this->event->id}/shifts/{$shift->id}/claim"); $response->assertUnauthorized(); } // ========================================================================= // Cancel // ========================================================================= public function test_successful_cancel(): 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/portal/events/{$this->event->id}/assignments/{$assignment->id}/cancel", ); $response->assertOk() ->assertJsonPath('data.message', 'Je dienst is geannuleerd.'); $this->assertDatabaseHas('shift_assignments', [ 'id' => $assignment->id, 'status' => ShiftAssignmentStatus::CANCELLED->value, 'cancellation_source' => 'volunteer', ]); } public function test_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()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $otherPerson->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/portal/events/{$this->event->id}/assignments/{$assignment->id}/cancel", ); $response->assertForbidden(); } public function test_cannot_cancel_completed_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::COMPLETED, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/portal/events/{$this->event->id}/assignments/{$assignment->id}/cancel", ); $response->assertUnprocessable(); } public function test_cannot_cancel_past_assignment(): void { $pastSlot = TimeSlot::factory()->create([ 'event_id' => $this->event->id, 'person_type' => 'VOLUNTEER', 'date' => now()->subMonth(), ]); $shift = $this->createOpenShift(['time_slot_id' => $pastSlot->id]); $assignment = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $pastSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->postJson( "/api/v1/portal/events/{$this->event->id}/assignments/{$assignment->id}/cancel", ); $response->assertUnprocessable(); } public function test_cancel_unauthenticated_returns_401(): void { $shift = $this->createOpenShift(); $assignment = ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ]); $response = $this->postJson( "/api/v1/portal/events/{$this->event->id}/assignments/{$assignment->id}/cancel", ); $response->assertUnauthorized(); } }