Files
crewli/api/tests/Feature/Event/FestivalEventTest.php

584 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Event;
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\TimeSlot;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class FestivalEventTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private Organisation $organisation;
private Event $festival;
private Event $subEvent;
private CrowdType $crowdType;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}