feat: portal cross-event my-shifts endpoint and dashboard page
GET /portal/my-shifts aggregates shift assignments across all events the logged-in user is linked to via Person records. Groups by event then date, showing only active assignments (approved/pending_approval) for approved/pending persons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
409
api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php
Normal file
409
api/tests/Feature/Api/V1/Portal/PortalAllMyShiftsTest.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Portal;
|
||||
|
||||
use App\Enums\ShiftAssignmentStatus;
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\FestivalSection;
|
||||
use App\Models\Location;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
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 PortalAllMyShiftsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $volunteer;
|
||||
private Organisation $organisation;
|
||||
private Event $event;
|
||||
private FestivalSection $section;
|
||||
private TimeSlot $timeSlot;
|
||||
private CrowdType $crowdType;
|
||||
private Person $person;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user