Files
crewli/api/tests/Feature/Shift/ShiftTest.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

368 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Shift;
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 Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ShiftTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $outsider;
private Organisation $organisation;
private Organisation $otherOrganisation;
private Event $event;
private FestivalSection $section;
private TimeSlot $timeSlot;
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->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,
]);
}
public function test_index_returns_shifts_for_section(): void
{
Shift::factory()->count(3)->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts");
$response->assertOk();
$this->assertCount(3, $response->json('data'));
}
public function test_store_creates_shift(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts", [
'time_slot_id' => $this->timeSlot->id,
'title' => 'Tapper',
'slots_total' => 4,
'slots_open_for_claiming' => 3,
]);
$response->assertCreated()
->assertJson(['data' => ['title' => 'Tapper', 'slots_total' => 4]]);
$this->assertDatabaseHas('shifts', [
'festival_section_id' => $this->section->id,
'title' => 'Tapper',
]);
}
public function test_update_shift(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'title' => 'Tapper',
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
'title' => 'Barhoofd',
'slots_total' => 1,
]);
$response->assertOk()
->assertJson(['data' => ['title' => 'Barhoofd', 'slots_total' => 1]]);
}
public function test_destroy_soft_deletes_shift(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}");
$response->assertNoContent();
$this->assertSoftDeleted('shifts', ['id' => $shift->id]);
}
public function test_store_missing_time_slot_id_returns_422(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts", [
'title' => 'Tapper',
'slots_total' => 4,
'slots_open_for_claiming' => 0,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('time_slot_id');
}
public function test_update_cross_org_returns_403(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->outsider);
$response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [
'title' => 'Hacked',
]);
$response->assertForbidden();
}
public function test_destroy_cross_org_returns_403(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
]);
Sanctum::actingAs($this->outsider);
$response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}");
$response->assertForbidden();
}
public function test_assign_creates_shift_assignment(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'status' => 'open',
]);
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", [
'person_id' => $person->id,
]);
$response->assertCreated()
->assertJsonPath('data.person_id', $person->id)
->assertJsonPath('data.status', 'approved');
$this->assertDatabaseHas('shift_assignments', [
'shift_id' => $shift->id,
'person_id' => $person->id,
'status' => 'approved',
]);
}
public function test_assign_same_person_same_timeslot_no_overlap_returns_422(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'allow_overlap' => false,
'status' => 'open',
]);
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
// Create existing assignment for this person + time_slot
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => 'approved',
'auto_approved' => false,
]);
// Try to assign again via a different shift with the same time_slot
$shift2 = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'allow_overlap' => false,
'status' => 'open',
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", [
'person_id' => $person->id,
]);
$response->assertUnprocessable();
}
public function test_assign_same_person_same_timeslot_with_overlap_returns_201(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'allow_overlap' => true,
'status' => 'open',
]);
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
// Create existing assignment
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person->id,
'time_slot_id' => $this->timeSlot->id,
'status' => 'approved',
'auto_approved' => false,
]);
// New shift with allow_overlap = true
$shift2 = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'allow_overlap' => true,
'status' => 'open',
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift2->id}/assign", [
'person_id' => $person->id,
]);
$response->assertCreated();
}
public function test_assign_full_shift_allows_overbooking(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 1,
'status' => 'open',
]);
$person1 = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
$person2 = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
// Fill the only slot
ShiftAssignment::create([
'shift_id' => $shift->id,
'person_id' => $person1->id,
'time_slot_id' => $this->timeSlot->id,
'status' => 'approved',
'auto_approved' => false,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/assign", [
'person_id' => $person2->id,
]);
$response->assertCreated();
$this->assertDatabaseHas('shift_assignments', [
'shift_id' => $shift->id,
'person_id' => $person2->id,
'status' => 'approved',
]);
}
public function test_claim_no_claimable_slots_returns_422(): void
{
$shift = Shift::factory()->create([
'festival_section_id' => $this->section->id,
'time_slot_id' => $this->timeSlot->id,
'slots_total' => 4,
'slots_open_for_claiming' => 0,
]);
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}/claim", [
'person_id' => $person->id,
]);
$response->assertUnprocessable();
}
public function test_unauthenticated_returns_401(): void
{
$response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts");
$response->assertUnauthorized();
}
public function test_cross_org_returns_403(): void
{
Sanctum::actingAs($this->outsider);
$response = $this->getJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts");
$response->assertForbidden();
}
}