Files
crewli/api/tests/Feature/Api/V1/ShiftAssignmentWorkflowTest.php
bert.hausmans 78cc19373e feat: allow organizer overbooking with confirmation dialog
Remove capacity and status validation from organizer assign flow so
organizers can intentionally overbook shifts. Log overbooked assignments
for audit trail. Volunteer claims still enforce hard limits. Frontend
shows a warning banner when a shift is full and requires confirmation
before overbooking.

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

795 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Enums\ShiftAssignmentStatus;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
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 App\Models\VolunteerAvailability;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ShiftAssignmentWorkflowTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private User $volunteer;
private Organisation $organisation;
private Organisation $otherOrganisation;
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->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->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,
'crew_auto_accepts' => false,
]);
$this->timeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]);
$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 createOpenShift(array $overrides = []): Shift
{
return 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,
], $overrides));
}
// =========================================================================
// Claim workflow
// =========================================================================
public function test_volunteer_can_claim_open_shift(): void
{
$shift = $this->createOpenShift();
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertCreated()
->assertJsonPath('data.status', 'pending_approval')
->assertJsonPath('data.person_id', $this->person->id);
$this->assertDatabaseHas('shift_assignments', [
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL->value,
]);
}
public function test_volunteer_can_claim_auto_approve_shift(): void
{
$autoSection = FestivalSection::factory()->create([
'event_id' => $this->event->id,
'crew_auto_accepts' => true,
]);
$shift = $this->createOpenShift(['festival_section_id' => $autoSection->id]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$autoSection->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertCreated()
->assertJsonPath('data.status', 'approved')
->assertJsonPath('data.auto_approved', true);
}
public function test_claim_rejected_when_shift_is_full(): void
{
$shift = $this->createOpenShift(['slots_open_for_claiming' => 1]);
// Fill the claimable slot
ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertUnprocessable();
}
public function test_claim_rejected_when_shift_status_not_open(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'slots_open_for_claiming' => 3,
'status' => 'draft',
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertUnprocessable();
}
public function test_claim_rejected_with_conflicting_assignment(): void
{
$shift1 = $this->createOpenShift();
$shift2 = $this->createOpenShift();
// Create existing active assignment for the same time slot
ShiftAssignment::factory()->create([
'shift_id' => $shift1->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::APPROVED,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim",
['person_id' => $this->person->id],
);
$response->assertUnprocessable();
}
public function test_claim_allowed_when_shift_allows_overlap(): void
{
$shift1 = $this->createOpenShift();
$shift2 = $this->createOpenShift(['allow_overlap' => true]);
// Create existing assignment
ShiftAssignment::factory()->create([
'shift_id' => $shift1->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::APPROVED,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/claim",
['person_id' => $this->person->id],
);
$response->assertCreated();
}
public function test_claim_rejected_when_person_not_approved(): void
{
$pendingPerson = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'status' => 'pending',
]);
$shift = $this->createOpenShift();
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $pendingPerson->id],
);
$response->assertUnprocessable();
}
public function test_unauthenticated_claim_returns_401(): void
{
$shift = $this->createOpenShift();
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertUnauthorized();
}
// =========================================================================
// Assign workflow
// =========================================================================
public function test_organizer_can_assign_person_to_shift(): void
{
$shift = $this->createOpenShift();
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
['person_id' => $this->person->id],
);
$response->assertCreated()
->assertJsonPath('data.status', 'approved')
->assertJsonPath('data.person_id', $this->person->id);
$this->assertDatabaseHas('shift_assignments', [
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'status' => ShiftAssignmentStatus::APPROVED->value,
'assigned_by' => $this->orgAdmin->id,
'approved_by' => $this->orgAdmin->id,
]);
}
public function test_assign_uses_slots_total_not_claiming(): void
{
// slots_open_for_claiming = 0, but slots_total = 4
$shift = $this->createOpenShift(['slots_open_for_claiming' => 0]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
['person_id' => $this->person->id],
);
$response->assertCreated();
}
public function test_assign_allows_overbooking_when_capacity_full(): void
{
$shift = $this->createOpenShift(['slots_total' => 1]);
// Fill the slot
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
['person_id' => $this->person->id],
);
$response->assertCreated();
$this->assertDatabaseHas('shift_assignments', [
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'status' => 'approved',
]);
}
public function test_assign_rejected_with_conflict(): void
{
$shift1 = $this->createOpenShift();
$shift2 = $this->createOpenShift();
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift1->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign",
['person_id' => $this->person->id],
);
$response->assertUnprocessable();
}
public function test_non_organizer_cannot_assign(): void
{
$shift = $this->createOpenShift();
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign",
['person_id' => $this->person->id],
);
$response->assertForbidden();
}
// =========================================================================
// Approve / Reject / Cancel
// =========================================================================
public function test_organizer_approves_pending_assignment(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve",
);
$response->assertOk()
->assertJsonPath('data.status', 'approved');
$this->assertDatabaseHas('shift_assignments', [
'id' => $assignment->id,
'status' => ShiftAssignmentStatus::APPROVED->value,
'approved_by' => $this->orgAdmin->id,
]);
}
public function test_organizer_rejects_with_reason(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/reject",
['reason' => 'Onvoldoende ervaring voor deze rol.'],
);
$response->assertOk()
->assertJsonPath('data.status', 'rejected')
->assertJsonPath('data.rejection_reason', 'Onvoldoende ervaring voor deze rol.');
}
public function test_cannot_approve_already_approved(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve",
);
$response->assertUnprocessable();
}
public function test_volunteer_can_cancel_own_pending_assignment(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel",
);
$response->assertOk()
->assertJsonPath('data.status', 'cancelled');
}
public function test_volunteer_can_cancel_own_approved_assignment(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel",
);
$response->assertOk()
->assertJsonPath('data.status', 'cancelled');
}
public function test_volunteer_cannot_cancel_someone_elses_assignment(): void
{
$otherPerson = Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $otherPerson->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->volunteer);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel",
);
$response->assertForbidden();
}
public function test_organizer_can_cancel_any_assignment(): void
{
$otherPerson = Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => $otherPerson->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/cancel",
);
$response->assertOk()
->assertJsonPath('data.status', 'cancelled');
}
// =========================================================================
// Bulk approve
// =========================================================================
public function test_bulk_approve_multiple_pending(): void
{
$shift = $this->createOpenShift();
$assignments = collect([1, 2])->map(fn () => ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]));
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/bulk-approve",
['assignment_ids' => $assignments->pluck('id')->toArray()],
);
$response->assertOk();
foreach ($assignments as $assignment) {
$this->assertDatabaseHas('shift_assignments', [
'id' => $assignment->id,
'status' => ShiftAssignmentStatus::APPROVED->value,
]);
}
}
public function test_bulk_approve_skips_non_pending(): void
{
$shift = $this->createOpenShift();
$pending = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
$approved = ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/bulk-approve",
['assignment_ids' => [$pending->id, $approved->id]],
);
$response->assertOk();
$results = $response->json('data');
$this->assertCount(2, $results);
$pendingResult = collect($results)->firstWhere('assignment_id', $pending->id);
$approvedResult = collect($results)->firstWhere('assignment_id', $approved->id);
$this->assertEquals('approved', $pendingResult['status']);
$this->assertEquals('skipped', $approvedResult['status']);
}
// =========================================================================
// Index / listing
// =========================================================================
public function test_index_returns_assignments_for_event(): void
{
$shift = $this->createOpenShift();
ShiftAssignment::factory()->count(3)->create([
'shift_id' => $shift->id,
'time_slot_id' => $this->timeSlot->id,
'person_id' => fn () => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/events/{$this->event->id}/shift-assignments");
$response->assertOk();
$this->assertCount(3, $response->json('data'));
}
public function test_index_filterable_by_status(): void
{
$shift = $this->createOpenShift();
ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'time_slot_id' => $this->timeSlot->id,
'person_id' => $this->person->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
ShiftAssignment::factory()->approved()->create([
'shift_id' => $shift->id,
'time_slot_id' => $this->timeSlot->id,
'person_id' => Person::factory()->approved()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
])->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/events/{$this->event->id}/shift-assignments?status=pending_approval");
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$this->assertEquals('pending_approval', $response->json('data.0.status'));
}
// =========================================================================
// Multi-tenancy
// =========================================================================
public function test_cannot_claim_shift_in_different_organisation(): void
{
$shift = $this->createOpenShift();
Sanctum::actingAs($this->outsider);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim",
['person_id' => $this->person->id],
);
$response->assertForbidden();
}
public function test_cannot_approve_assignment_in_different_organisation(): void
{
$shift = $this->createOpenShift();
$assignment = ShiftAssignment::factory()->create([
'shift_id' => $shift->id,
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => ShiftAssignmentStatus::PENDING_APPROVAL,
]);
Sanctum::actingAs($this->outsider);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/shift-assignments/{$assignment->id}/approve",
);
$response->assertForbidden();
}
// =========================================================================
// Volunteer Availabilities
// =========================================================================
public function test_sync_availabilities_for_person(): void
{
$slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync",
[
'availabilities' => [
['time_slot_id' => $this->timeSlot->id, 'preference_level' => 5],
['time_slot_id' => $slot2->id, 'preference_level' => 2],
],
],
);
$response->assertOk();
$this->assertDatabaseCount('volunteer_availabilities', 2);
$this->assertDatabaseHas('volunteer_availabilities', [
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'preference_level' => 5,
]);
}
public function test_sync_replaces_existing_availabilities(): void
{
// Create initial availabilities
VolunteerAvailability::factory()->create([
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'preference_level' => 3,
]);
$slot2 = TimeSlot::factory()->create(['event_id' => $this->event->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync",
[
'availabilities' => [
['time_slot_id' => $slot2->id, 'preference_level' => 4],
],
],
);
$response->assertOk();
// Old one removed, only new one exists
$this->assertDatabaseCount('volunteer_availabilities', 1);
$this->assertDatabaseHas('volunteer_availabilities', [
'person_id' => $this->person->id,
'time_slot_id' => $slot2->id,
'preference_level' => 4,
]);
$this->assertDatabaseMissing('volunteer_availabilities', [
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
]);
}
public function test_sync_rejects_time_slot_from_wrong_event(): void
{
$otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]);
$otherSlot = TimeSlot::factory()->create(['event_id' => $otherEvent->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync",
[
'availabilities' => [
['time_slot_id' => $otherSlot->id, 'preference_level' => 3],
],
],
);
$response->assertUnprocessable();
}
public function test_unauthenticated_sync_returns_401(): void
{
$response = $this->postJson(
"/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities/sync",
['availabilities' => []],
);
$response->assertUnauthorized();
}
public function test_list_availabilities_for_person(): void
{
VolunteerAvailability::factory()->create([
'person_id' => $this->person->id,
'time_slot_id' => $this->timeSlot->id,
'preference_level' => 4,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson(
"/api/v1/events/{$this->event->id}/persons/{$this->person->id}/availabilities",
);
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$this->assertEquals(4, $response->json('data.0.preference_level'));
}
}