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, ]); $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 createShiftWithAssignment(array $shiftOverrides = [], array $assignmentOverrides = []): ShiftAssignment { $shift = 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, ], $shiftOverrides)); // Use approved() only when no explicit status override is given, // because approved() uses afterCreating() which would override create() attributes. $factory = ShiftAssignment::factory(); if (! array_key_exists('status', $assignmentOverrides)) { $factory = $factory->approved(); } return $factory->create(array_merge([ 'shift_id' => $shift->id, 'person_id' => $this->person->id, 'time_slot_id' => $this->timeSlot->id, ], $assignmentOverrides)); } // ========================================================================= // Happy path // ========================================================================= public function test_returns_shifts_for_linked_persons(): void { $this->createShiftWithAssignment(['title' => 'Tapper']); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk() ->assertJsonStructure([ 'data' => [ '*' => [ 'event' => ['id', 'name', 'start_date', 'end_date'], 'assignments' => [ '*' => [ 'date', 'date_label', 'shifts' => [ '*' => [ 'id', 'status', 'shift' => [ 'id', 'title', 'section_name', 'section_icon', 'time_slot_name', 'date', 'start_time', 'end_time', 'report_time', 'location', ], ], ], ], ], ], ], ]); $this->assertCount(1, $response->json('data')); $this->assertEquals($this->event->id, $response->json('data.0.event.id')); $this->assertEquals('Tapper', $response->json('data.0.assignments.0.shifts.0.shift.title')); } // ========================================================================= // Empty results // ========================================================================= public function test_returns_empty_when_user_has_no_linked_persons(): void { $otherUser = User::factory()->create(); Sanctum::actingAs($otherUser); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk() ->assertJsonPath('data', []); } public function test_returns_empty_when_no_active_assignments(): void { // Person exists but no assignments Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk() ->assertJsonPath('data', []); } // ========================================================================= // Status filtering // ========================================================================= public function test_only_returns_approved_and_pending_approval_assignments(): void { // Approved — should appear $this->createShiftWithAssignment( ['title' => 'Approved Shift'], ['status' => ShiftAssignmentStatus::APPROVED], ); // Pending approval — should appear $this->createShiftWithAssignment( ['title' => 'Pending Shift'], ['status' => ShiftAssignmentStatus::PENDING_APPROVAL], ); // Cancelled — should NOT appear $this->createShiftWithAssignment( ['title' => 'Cancelled Shift'], ['status' => ShiftAssignmentStatus::CANCELLED], ); // Rejected — should NOT appear $this->createShiftWithAssignment( ['title' => 'Rejected Shift'], ['status' => ShiftAssignmentStatus::REJECTED], ); // Completed — should NOT appear $this->createShiftWithAssignment( ['title' => 'Completed Shift'], ['status' => ShiftAssignmentStatus::COMPLETED], ); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); $allShifts = collect($response->json('data')) ->flatMap(fn ($e) => collect($e['assignments'])) ->flatMap(fn ($d) => $d['shifts']); $this->assertCount(2, $allShifts); $statuses = $allShifts->pluck('status')->sort()->values()->all(); $this->assertEquals(['approved', 'pending_approval'], $statuses); } public function test_excludes_persons_with_rejected_status(): void { // Create a rejected person with an approved assignment $rejectedPerson = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $this->volunteer->id, 'status' => 'rejected', ]); $shift = Shift::factory()->open()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift->id, 'person_id' => $rejectedPerson->id, 'time_slot_id' => $this->timeSlot->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); // Only the approved person should contribute shifts $allShifts = collect($response->json('data')) ->flatMap(fn ($e) => collect($e['assignments'])) ->flatMap(fn ($d) => $d['shifts']); $this->assertCount(0, $allShifts); } // ========================================================================= // Grouping // ========================================================================= public function test_grouped_by_event_and_date(): void { // Create a second event with its own person and assignment $event2 = Event::factory()->create(['organisation_id' => $this->organisation->id]); $section2 = FestivalSection::factory()->create(['event_id' => $event2->id]); $timeSlot2 = TimeSlot::factory()->create([ 'event_id' => $event2->id, 'person_type' => 'VOLUNTEER', 'date' => now()->addMonths(2), ]); $crowdType2 = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); $person2 = Person::factory()->approved()->create([ 'event_id' => $event2->id, 'crowd_type_id' => $crowdType2->id, 'user_id' => $this->volunteer->id, ]); // Assignment in event 1 $this->createShiftWithAssignment(['title' => 'Event 1 Shift']); // Assignment in event 2 $shift2 = Shift::factory()->open()->create([ 'festival_section_id' => $section2->id, 'time_slot_id' => $timeSlot2->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, 'title' => 'Event 2 Shift', ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift2->id, 'person_id' => $person2->id, 'time_slot_id' => $timeSlot2->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); // Should have 2 event groups $this->assertCount(2, $response->json('data')); $eventIds = collect($response->json('data'))->pluck('event.id')->sort()->values()->all(); $this->assertContains($this->event->id, $eventIds); $this->assertContains($event2->id, $eventIds); } public function test_multiple_dates_within_same_event(): void { $timeSlot2 = TimeSlot::factory()->create([ 'event_id' => $this->event->id, 'person_type' => 'VOLUNTEER', 'date' => now()->addMonths(2), ]); // Day 1 shift $this->createShiftWithAssignment(['title' => 'Day 1 Shift']); // Day 2 shift $shift2 = Shift::factory()->open()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $timeSlot2->id, 'slots_total' => 4, 'slots_open_for_claiming' => 3, 'title' => 'Day 2 Shift', ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shift2->id, 'person_id' => $this->person->id, 'time_slot_id' => $timeSlot2->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); // 1 event group with 2 date groups $this->assertCount(1, $response->json('data')); $this->assertCount(2, $response->json('data.0.assignments')); } // ========================================================================= // Authentication // ========================================================================= public function test_unauthenticated_returns_401(): void { $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertUnauthorized(); } // ========================================================================= // Response data // ========================================================================= public function test_shift_includes_location_data(): void { $location = Location::factory()->create([ 'event_id' => $this->event->id, 'name' => 'Hoofdpodium', 'address' => 'Festivalplein 1', ]); $this->createShiftWithAssignment([ 'title' => 'Shift met locatie', 'location_id' => $location->id, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); $shiftData = $response->json('data.0.assignments.0.shifts.0.shift'); $this->assertEquals('Hoofdpodium', $shiftData['location']['name']); $this->assertEquals('Festivalplein 1', $shiftData['location']['address']); } public function test_shift_without_location_returns_null(): void { $this->createShiftWithAssignment([ 'title' => 'Shift zonder locatie', 'location_id' => null, ]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); $this->assertNull($response->json('data.0.assignments.0.shifts.0.shift.location')); } public function test_shift_title_falls_back_to_section_name(): void { $this->createShiftWithAssignment(['title' => null]); Sanctum::actingAs($this->volunteer); $response = $this->getJson('/api/v1/portal/my-shifts'); $response->assertOk(); $shiftTitle = $response->json('data.0.assignments.0.shifts.0.shift.title'); $this->assertEquals($this->section->name, $shiftTitle); } }