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->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); } public function test_event_stats_returns_correct_counts(): void { // Create persons with various statuses Person::factory()->count(5)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'approved', ]); Person::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'pending', ]); Person::factory()->count(2)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'rejected', ]); // Create a section, time slot, and shifts $section = FestivalSection::factory()->create([ 'event_id' => $this->event->id, ]); $timeSlot = TimeSlot::factory()->create([ 'event_id' => $this->event->id, ]); $shiftFull = Shift::factory()->create([ 'festival_section_id' => $section->id, 'time_slot_id' => $timeSlot->id, 'slots_total' => 2, ]); $shiftPartial = Shift::factory()->create([ 'festival_section_id' => $section->id, 'time_slot_id' => $timeSlot->id, 'slots_total' => 3, ]); // Assign 2 approved persons to shiftFull (fills it) $approvedPersons = $this->event->persons()->where('status', 'approved')->get(); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shiftFull->id, 'person_id' => $approvedPersons[0]->id, 'time_slot_id' => $timeSlot->id, ]); ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shiftFull->id, 'person_id' => $approvedPersons[1]->id, 'time_slot_id' => $timeSlot->id, ]); // Assign 1 approved person to shiftPartial (understaffed: 1/3) ShiftAssignment::factory()->approved()->create([ 'shift_id' => $shiftPartial->id, 'person_id' => $approvedPersons[2]->id, 'time_slot_id' => $timeSlot->id, ]); // Create a pending identity match PersonIdentityMatch::factory()->create([ 'person_id' => $approvedPersons[3]->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); $response->assertOk(); $data = $response->json('data'); $this->assertEquals(10, $data['persons_total']); $this->assertEquals(5, $data['persons_approved']); $this->assertEquals(3, $data['persons_pending']); $this->assertEquals(2, $data['persons_rejected']); $this->assertEquals(0, $data['persons_other']); // 5 approved - 3 with assignments = 2 without shift $this->assertEquals(2, $data['persons_approved_without_shift']); $this->assertEquals(1, $data['pending_identity_matches']); $this->assertEquals(2, $data['shifts_total']); $this->assertEquals(1, $data['shifts_filled']); $this->assertEquals(1, $data['shifts_understaffed']); } public function test_event_stats_scoped_to_event(): void { $otherEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, ]); // Create persons on both events Person::factory()->count(3)->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'approved', ]); Person::factory()->count(5)->create([ 'event_id' => $otherEvent->id, 'crowd_type_id' => $this->crowdType->id, 'status' => 'approved', ]); // Create identity matches on other event (should not count) $otherPerson = $otherEvent->persons()->first(); PersonIdentityMatch::factory()->create([ 'person_id' => $otherPerson->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); $response->assertOk(); $data = $response->json('data'); $this->assertEquals(3, $data['persons_total']); $this->assertEquals(3, $data['persons_approved']); $this->assertEquals(0, $data['pending_identity_matches']); } public function test_unauthenticated_cannot_access_stats(): void { $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); $response->assertUnauthorized(); } public function test_cross_org_cannot_access_stats(): void { Sanctum::actingAs($this->outsider); $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); $response->assertForbidden(); } public function test_event_stats_returns_zeros_when_empty(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/stats"); $response->assertOk(); $data = $response->json('data'); $this->assertEquals(0, $data['persons_total']); $this->assertEquals(0, $data['persons_approved']); $this->assertEquals(0, $data['persons_pending']); $this->assertEquals(0, $data['persons_rejected']); $this->assertEquals(0, $data['persons_other']); $this->assertEquals(0, $data['persons_approved_without_shift']); $this->assertEquals(0, $data['pending_identity_matches']); $this->assertEquals(0, $data['shifts_total']); $this->assertEquals(0, $data['shifts_filled']); $this->assertEquals(0, $data['shifts_understaffed']); } }