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>
This commit is contained in:
208
api/tests/Feature/Event/EventStatsTest.php
Normal file
208
api/tests/Feature/Event/EventStatsTest.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user