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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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_update_shift_with_valid_time_slot_id(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, ]); $newTimeSlot = TimeSlot::factory()->create(['event_id' => $this->event->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [ 'time_slot_id' => $newTimeSlot->id, ]); $response->assertOk() ->assertJsonPath('data.time_slot_id', $newTimeSlot->id) ->assertJsonPath('data.time_slot.id', $newTimeSlot->id) ->assertJsonPath('data.time_slot.name', $newTimeSlot->name); } public function test_update_shift_with_other_org_time_slot_returns_422(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, ]); $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); $otherTimeSlot = TimeSlot::factory()->create(['event_id' => $otherEvent->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [ 'time_slot_id' => $otherTimeSlot->id, ]); $response->assertUnprocessable() ->assertJsonValidationErrors('time_slot_id'); } public function test_update_shift_response_includes_time_slot_object(): void { $shift = Shift::factory()->create([ 'festival_section_id' => $this->section->id, 'time_slot_id' => $this->timeSlot->id, 'title' => 'Runner', ]); Sanctum::actingAs($this->orgAdmin); $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [ 'title' => 'Stage Manager', ]); $response->assertOk() ->assertJsonStructure(['data' => [ 'time_slot_id', 'time_slot' => ['id', 'name', 'date', 'start_time', 'end_time'], ]]); } public function test_store_response_includes_time_slot_object(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/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() ->assertJsonStructure(['data' => [ 'time_slot_id', 'time_slot' => ['id', 'name', 'date', 'start_time', 'end_time'], ]]); } 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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/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/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts"); $response->assertForbidden(); } public function test_create_shift_with_parent_festival_time_slot_succeeds(): void { $festival = Event::factory()->create([ 'organisation_id' => $this->organisation->id, 'event_type' => 'festival', ]); $subEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, 'parent_event_id' => $festival->id, ]); $festivalSlot = TimeSlot::factory()->create(['event_id' => $festival->id]); $subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id, 'type' => 'standard']); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}/sections/{$subSection->id}/shifts", [ 'time_slot_id' => $festivalSlot->id, 'title' => 'Opbouw shift', 'slots_total' => 5, 'slots_open_for_claiming' => 0, ], ); $response->assertCreated(); } public function test_create_shift_in_cross_event_section_with_child_time_slot_succeeds(): void { $festival = Event::factory()->create([ 'organisation_id' => $this->organisation->id, 'event_type' => 'festival', ]); $subEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, 'parent_event_id' => $festival->id, ]); $subEventSlot = TimeSlot::factory()->create(['event_id' => $subEvent->id]); $crossSection = FestivalSection::factory()->create([ 'event_id' => $festival->id, 'type' => 'cross_event', ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/sections/{$crossSection->id}/shifts", [ 'time_slot_id' => $subEventSlot->id, 'title' => 'EHBO Post', 'slots_total' => 3, 'slots_open_for_claiming' => 2, ], ); $response->assertCreated(); } public function test_create_shift_with_unrelated_event_time_slot_fails_422(): void { $unrelatedEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, ]); $unrelatedSlot = TimeSlot::factory()->create(['event_id' => $unrelatedEvent->id]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/sections/{$this->section->id}/shifts", [ 'time_slot_id' => $unrelatedSlot->id, 'title' => 'Invalid shift', 'slots_total' => 1, 'slots_open_for_claiming' => 0, ], ); $response->assertUnprocessable() ->assertJsonValidationErrors('time_slot_id'); } }