584 lines
19 KiB
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);
|
|
}
|
|
}
|