Files
crewli/api/tests/Feature/Event/EventStatsTest.php
bert.hausmans 874eeee770 feat: event dashboard metric cards with stats endpoint (UX-02)
Add GET /events/{event}/stats endpoint returning aggregate counts for
persons (by status, approved without shift), pending identity matches,
and shift fill rates. Frontend metric cards component shows four
actionable KPIs on the event overview tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:19:31 +02:00

209 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Event;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonIdentityMatch;
use App\Models\Shift;
use App\Models\ShiftAssignment;
use App\Models\TimeSlot;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class EventStatsTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private Organisation $organisation;
private Organisation $otherOrganisation;
private Event $event;
private CrowdType $crowdType;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}