seed(RoleSeeder::class); $this->organisation = Organisation::factory()->create(); $this->orgAdmin = User::factory()->create(); $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); $this->festival = Event::factory()->festival()->create([ 'organisation_id' => $this->organisation->id, ]); $this->subEvent = Event::factory()->subEvent($this->festival)->create(); $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); } // --- Festival-level time slots --- public function test_can_create_time_slot_on_festival_parent(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->festival->id}/time-slots", [ 'name' => 'Opbouw Vrijdag', 'person_type' => 'CREW', 'date' => '2026-07-10', 'start_time' => '08:00', 'end_time' => '18:00', 'duration_hours' => 10, ]); $response->assertCreated() ->assertJson(['data' => [ 'name' => 'Opbouw Vrijdag', 'person_type' => 'CREW', ]]); $this->assertDatabaseHas('time_slots', [ 'event_id' => $this->festival->id, 'name' => 'Opbouw Vrijdag', ]); } // --- Festival-level sections --- public function test_can_create_section_on_festival_parent(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [ 'name' => 'Terreinploeg', 'sort_order' => 1, 'type' => 'standard', ]); $response->assertCreated() ->assertJson(['data' => [ 'name' => 'Terreinploeg', 'type' => 'standard', ]]); $this->assertDatabaseHas('festival_sections', [ 'event_id' => $this->festival->id, 'name' => 'Terreinploeg', ]); } // --- Festival-level shifts --- public function test_can_create_shift_on_festival_level_section(): void { $section = FestivalSection::factory()->create([ 'event_id' => $this->festival->id, ]); $timeSlot = TimeSlot::factory()->create([ 'event_id' => $this->festival->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections/{$section->id}/shifts", [ 'time_slot_id' => $timeSlot->id, 'title' => 'Terreinmedewerker', 'slots_total' => 8, 'slots_open_for_claiming' => 4, ]); $response->assertCreated() ->assertJson(['data' => [ 'title' => 'Terreinmedewerker', 'slots_total' => 8, ]]); $this->assertDatabaseHas('shifts', [ 'festival_section_id' => $section->id, 'title' => 'Terreinmedewerker', ]); } // --- Cross-event sections --- public function test_cross_event_section_appears_in_sub_event_sections(): void { // Create a cross_event section on the festival parent FestivalSection::factory()->crossEvent()->create([ 'event_id' => $this->festival->id, 'name' => 'EHBO', 'sort_order' => 0, ]); // Create a standard section on the sub-event FestivalSection::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Bar', 'sort_order' => 1, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/sections"); $response->assertOk(); $sectionNames = collect($response->json('data'))->pluck('name')->all(); // cross_event section from parent should be included $this->assertContains('EHBO', $sectionNames); // sub-event's own section should be included $this->assertContains('Bar', $sectionNames); } // --- Festival time slots stay separate --- public function test_festival_level_time_slots_not_included_in_sub_event_time_slots(): void { // Create a time slot on the festival parent (operational) TimeSlot::factory()->create([ 'event_id' => $this->festival->id, 'name' => 'Opbouw', ]); // Create a time slot on the sub-event TimeSlot::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Zaterdag Avond', ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots"); $response->assertOk(); $slotNames = collect($response->json('data'))->pluck('name')->all(); // Sub-event's own time slot should be there $this->assertContains('Zaterdag Avond', $slotNames); // Festival-level operational time slot should NOT be there $this->assertNotContains('Opbouw', $slotNames); } // --- Persons at festival level --- public function test_persons_on_festival_level(): void { // Create a person on the festival parent Person::factory()->create([ 'event_id' => $this->festival->id, 'crowd_type_id' => $this->crowdType->id, 'name' => 'Jan Festivalmedewerker', ]); // Create a person on the sub-event Person::factory()->create([ 'event_id' => $this->subEvent->id, 'crowd_type_id' => $this->crowdType->id, 'name' => 'Piet Dagvrijwilliger', ]); Sanctum::actingAs($this->orgAdmin); // Query persons on festival level — should only return festival-level persons $response = $this->getJson("/api/v1/events/{$this->festival->id}/persons"); $response->assertOk(); $personNames = collect($response->json('data'))->pluck('name')->all(); $this->assertContains('Jan Festivalmedewerker', $personNames); $this->assertNotContains('Piet Dagvrijwilliger', $personNames); } // --- Cross-event section auto-redirect --- public function test_create_cross_event_section_on_sub_event_redirects_to_parent(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ 'name' => 'EHBO', 'type' => 'cross_event', ]); $response->assertCreated(); // Section should be created on the parent festival, not on the sub-event $this->assertDatabaseHas('festival_sections', [ 'event_id' => $this->festival->id, 'name' => 'EHBO', 'type' => 'cross_event', ]); $this->assertDatabaseMissing('festival_sections', [ 'event_id' => $this->subEvent->id, 'name' => 'EHBO', ]); // Response should include redirect meta $response->assertJsonPath('meta.redirected_to_parent', true); $response->assertJsonPath('meta.parent_event_name', $this->festival->name); } public function test_create_cross_event_section_on_festival_parent_works_normally(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [ 'name' => 'Security', 'type' => 'cross_event', ]); $response->assertCreated(); $this->assertDatabaseHas('festival_sections', [ 'event_id' => $this->festival->id, 'name' => 'Security', 'type' => 'cross_event', ]); // No redirect meta on direct creation $response->assertJsonMissing(['redirected_to_parent' => true]); } public function test_create_cross_event_section_on_flat_event_returns_422(): void { $flatEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$flatEvent->id}/sections", [ 'name' => 'EHBO', 'type' => 'cross_event', ]); $response->assertUnprocessable(); } public function test_cross_event_section_created_via_sub_event_appears_in_all_siblings(): void { $subEventB = Event::factory()->subEvent($this->festival)->create(); Sanctum::actingAs($this->orgAdmin); // Create cross_event via sub-event A → redirects to parent $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ 'name' => 'Verkeersregelaars', 'type' => 'cross_event', ])->assertCreated(); // Should appear in sub-event B's section list $response = $this->getJson("/api/v1/events/{$subEventB->id}/sections"); $response->assertOk(); $sectionNames = collect($response->json('data'))->pluck('name')->all(); $this->assertContains('Verkeersregelaars', $sectionNames); } public function test_create_standard_section_on_sub_event_stays_on_sub_event(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ 'name' => 'Bar Schirmbar', 'type' => 'standard', ]); $response->assertCreated(); // Should stay on the sub-event $this->assertDatabaseHas('festival_sections', [ 'event_id' => $this->subEvent->id, 'name' => 'Bar Schirmbar', 'type' => 'standard', ]); } // --- Model helper: getAllRelevantTimeSlots --- public function test_get_all_relevant_time_slots_for_festival(): void { TimeSlot::factory()->create([ 'event_id' => $this->festival->id, 'name' => 'Opbouw', ]); TimeSlot::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Zaterdag Avond', ]); $allSlots = $this->festival->getAllRelevantTimeSlots(); $slotNames = $allSlots->pluck('name')->all(); $this->assertContains('Opbouw', $slotNames); $this->assertContains('Zaterdag Avond', $slotNames); } public function test_get_all_relevant_time_slots_for_sub_event(): void { TimeSlot::factory()->create([ 'event_id' => $this->festival->id, 'name' => 'Opbouw', ]); TimeSlot::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Zaterdag Avond', ]); $allSlots = $this->subEvent->getAllRelevantTimeSlots(); $slotNames = $allSlots->pluck('name')->all(); $this->assertContains('Opbouw', $slotNames); $this->assertContains('Zaterdag Avond', $slotNames); } public function test_get_all_relevant_time_slots_for_flat_event(): void { $flatEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, ]); TimeSlot::factory()->create([ 'event_id' => $flatEvent->id, 'name' => 'Avond', ]); $allSlots = $flatEvent->getAllRelevantTimeSlots(); $this->assertCount(1, $allSlots); $this->assertEquals('Avond', $allSlots->first()->name); } // --- include_parent time slots for sub-events --- public function test_sub_event_time_slots_include_parent_festival_time_slots(): void { TimeSlot::factory()->create([ 'event_id' => $this->festival->id, 'name' => 'Opbouw', ]); TimeSlot::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Zaterdag Avond', ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots?include_parent=true"); $response->assertOk(); $slots = collect($response->json('data')); $slotNames = $slots->pluck('name')->all(); $this->assertContains('Zaterdag Avond', $slotNames); $this->assertContains('Opbouw', $slotNames); // Verify source markers $subEventSlot = $slots->firstWhere('name', 'Zaterdag Avond'); $festivalSlot = $slots->firstWhere('name', 'Opbouw'); $this->assertEquals('sub_event', $subEventSlot['source']); $this->assertEquals('festival', $festivalSlot['source']); // Verify event_name is present $this->assertEquals($this->festival->name, $festivalSlot['event_name']); $this->assertEquals($this->subEvent->name, $subEventSlot['event_name']); } public function test_festival_time_slots_do_not_include_sub_event_time_slots(): void { TimeSlot::factory()->create([ 'event_id' => $this->festival->id, 'name' => 'Opbouw', ]); TimeSlot::factory()->create([ 'event_id' => $this->subEvent->id, 'name' => 'Zaterdag Avond', ]); Sanctum::actingAs($this->orgAdmin); // Even with include_parent, festival parent should only return its own TS $response = $this->getJson("/api/v1/events/{$this->festival->id}/time-slots?include_parent=true"); $response->assertOk(); $slotNames = collect($response->json('data'))->pluck('name')->all(); $this->assertContains('Opbouw', $slotNames); $this->assertNotContains('Zaterdag Avond', $slotNames); } public function test_create_shift_on_local_section_with_festival_time_slot(): void { $section = FestivalSection::factory()->create([ 'event_id' => $this->subEvent->id, ]); // Time slot belongs to the parent festival $festivalTimeSlot = TimeSlot::factory()->create([ 'event_id' => $this->festival->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [ 'time_slot_id' => $festivalTimeSlot->id, 'title' => 'Opbouwshift', 'slots_total' => 4, 'slots_open_for_claiming' => 0, ]); $response->assertCreated(); $this->assertDatabaseHas('shifts', [ 'festival_section_id' => $section->id, 'time_slot_id' => $festivalTimeSlot->id, 'title' => 'Opbouwshift', ]); } public function test_create_shift_on_local_section_with_other_event_time_slot_returns_422(): void { $section = FestivalSection::factory()->create([ 'event_id' => $this->subEvent->id, ]); // Time slot from a completely unrelated event $otherOrg = Organisation::factory()->create(); $otherEvent = Event::factory()->create([ 'organisation_id' => $otherOrg->id, ]); $otherTimeSlot = TimeSlot::factory()->create([ 'event_id' => $otherEvent->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [ 'time_slot_id' => $otherTimeSlot->id, 'title' => 'Illegale shift', 'slots_total' => 1, 'slots_open_for_claiming' => 0, ]); $response->assertUnprocessable(); } public function test_flat_event_time_slots_unchanged(): void { $flatEvent = Event::factory()->create([ 'organisation_id' => $this->organisation->id, ]); TimeSlot::factory()->create([ 'event_id' => $flatEvent->id, 'name' => 'Avond', ]); Sanctum::actingAs($this->orgAdmin); // include_parent has no effect on flat events $response = $this->getJson("/api/v1/events/{$flatEvent->id}/time-slots?include_parent=true"); $response->assertOk(); $slots = collect($response->json('data')); $this->assertCount(1, $slots); $this->assertEquals('Avond', $slots->first()['name']); // No source marker on flat events $this->assertNull($slots->first()['source']); } public function test_conflict_detection_across_event_levels(): void { // Section on sub-event $section = FestivalSection::factory()->create([ 'event_id' => $this->subEvent->id, ]); // Time slot on festival parent $festivalTimeSlot = TimeSlot::factory()->create([ 'event_id' => $this->festival->id, ]); // Create a shift on the sub-event section using festival time slot $shift = Shift::factory()->create([ 'festival_section_id' => $section->id, 'time_slot_id' => $festivalTimeSlot->id, 'slots_total' => 5, 'allow_overlap' => false, ]); // Create a person $person = Person::factory()->create([ 'event_id' => $this->subEvent->id, 'crowd_type_id' => $this->crowdType->id, ]); Sanctum::actingAs($this->orgAdmin); // Assign person to the shift $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts/{$shift->id}/assign", [ 'person_id' => $person->id, ])->assertCreated(); // Create another section and shift with the same festival time slot $section2 = FestivalSection::factory()->create([ 'event_id' => $this->subEvent->id, ]); $shift2 = Shift::factory()->create([ 'festival_section_id' => $section2->id, 'time_slot_id' => $festivalTimeSlot->id, 'slots_total' => 5, 'allow_overlap' => false, ]); // Assigning the same person to same time_slot should fail $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section2->id}/shifts/{$shift2->id}/assign", [ 'person_id' => $person->id, ]); $response->assertStatus(422); } }