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_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, ]); $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, ]); 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, ]); $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, ]); 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_returns_422(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'slots_total' => 1, ]); $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->assertUnprocessable(); } 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(); } }