feat: festival/series model with sub-events, cross-event sections, tab navigation, SectionsShiftsPanel extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:15:19 +02:00
parent 11b9f1d399
commit 10bd55b8ae
40 changed files with 3087 additions and 1080 deletions

View File

@@ -284,4 +284,122 @@ class EventTest extends TestCase
$response->assertUnauthorized();
}
// --- DESTROY ---
public function test_org_admin_can_soft_delete_event(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertOk();
$this->assertSoftDeleted('events', ['id' => $event->id]);
}
public function test_org_admin_can_soft_delete_sub_event(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
$subEvent = Event::factory()->subEvent($festival)->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}");
$response->assertOk();
$this->assertSoftDeleted('events', ['id' => $subEvent->id]);
$this->assertNotSoftDeleted('events', ['id' => $festival->id]);
}
public function test_event_manager_can_delete_event(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$eventManager = User::factory()->create();
$this->organisation->users()->attach($eventManager, ['role' => 'org_member']);
$event->users()->attach($eventManager, ['role' => 'event_manager']);
Sanctum::actingAs($eventManager);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertOk();
$this->assertSoftDeleted('events', ['id' => $event->id]);
}
public function test_org_member_cannot_delete_event(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
Sanctum::actingAs($this->orgMember);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertForbidden();
}
public function test_outsider_cannot_delete_event(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
Sanctum::actingAs($this->outsider);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertForbidden();
}
public function test_unauthenticated_user_cannot_delete_event(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertUnauthorized();
}
public function test_delete_event_from_other_org_is_blocked(): void
{
$otherOrg = Organisation::factory()->create();
$event = Event::factory()->create(['organisation_id' => $otherOrg->id]);
Sanctum::actingAs($this->admin);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertForbidden();
}
public function test_soft_deleted_event_not_in_index(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$event->delete();
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
$response->assertOk();
$this->assertCount(0, $response->json('data'));
}
public function test_soft_delete_does_not_cascade_to_related_data(): void
{
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
$section = $event->festivalSections()->create([
'name' => 'Stage A',
'sort_order' => 1,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
$response->assertOk();
$this->assertSoftDeleted('events', ['id' => $event->id]);
$this->assertDatabaseHas('festival_sections', ['id' => $section->id, 'deleted_at' => null]);
}
}

View File

@@ -4,8 +4,13 @@ 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;
@@ -18,6 +23,9 @@ class FestivalEventTest extends TestCase
private User $orgAdmin;
private Organisation $organisation;
private Event $festival;
private Event $subEvent;
private CrowdType $crowdType;
protected function setUp(): void
{
@@ -28,207 +36,548 @@ class FestivalEventTest extends TestCase
$this->orgAdmin = User::factory()->create();
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
}
// --- INDEX: top-level only ---
public function test_index_shows_only_top_level_events(): void
{
$festival = Event::factory()->festival()->create([
$this->festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
Event::factory()->subEvent($festival)->create();
Event::factory()->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->getJson("/api/v1/organisations/{$this->organisation->id}/events");
$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();
$this->assertCount(2, $response->json('data'));
$ids = collect($response->json('data'))->pluck('parent_event_id');
$this->assertTrue($ids->every(fn ($v) => $v === null));
$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);
}
// --- INDEX: include_children ---
// --- Festival time slots stay separate ---
public function test_index_with_include_children_shows_nested_children(): void
public function test_festival_level_time_slots_not_included_in_sub_event_time_slots(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
// 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',
]);
Event::factory()->subEvent($festival)->count(2)->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?include_children=true");
$response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots");
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$this->assertCount(2, $response->json('data.0.children'));
$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);
}
// --- INDEX: type filter ---
// --- Persons at festival level ---
public function test_index_type_filter_works(): void
public function test_persons_on_festival_level(): void
{
Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
// 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',
]);
Event::factory()->create(['organisation_id' => $this->organisation->id]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?type=festival");
// Query persons on festival level — should only return festival-level persons
$response = $this->getJson("/api/v1/events/{$this->festival->id}/persons");
$response->assertOk();
$this->assertCount(1, $response->json('data'));
$this->assertEquals('festival', $response->json('data.0.event_type'));
$personNames = collect($response->json('data'))->pluck('name')->all();
$this->assertContains('Jan Festivalmedewerker', $personNames);
$this->assertNotContains('Piet Dagvrijwilliger', $personNames);
}
// --- STORE: sub-event ---
// --- Cross-event section auto-redirect ---
public function test_store_with_parent_event_id_creates_sub_event(): void
public function test_create_cross_event_section_on_sub_event_redirects_to_parent(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
'name' => 'Dag 1',
'slug' => 'dag-1',
'start_date' => '2026-07-01',
'end_date' => '2026-07-01',
'parent_event_id' => $festival->id,
$response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [
'name' => 'EHBO',
'type' => 'cross_event',
]);
$response->assertCreated();
$this->assertEquals($festival->id, $response->json('data.parent_event_id'));
$this->assertTrue($response->json('data.is_sub_event'));
// 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);
}
// --- STORE: sub-event cross-org → 422 ---
public function test_store_sub_event_of_other_org_returns_422(): void
public function test_create_cross_event_section_on_festival_parent_works_normally(): void
{
$otherOrg = Organisation::factory()->create();
$otherEvent = Event::factory()->festival()->create([
'organisation_id' => $otherOrg->id,
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/organisations/{$this->organisation->id}/events", [
'name' => 'Cross-org sub',
'slug' => 'cross-org-sub',
'start_date' => '2026-07-01',
'end_date' => '2026-07-01',
'parent_event_id' => $otherEvent->id,
$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);
}
// --- CHILDREN endpoint ---
public function test_children_endpoint_returns_sub_events(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
Event::factory()->subEvent($festival)->count(3)->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/children");
$response->assertOk();
$this->assertCount(3, $response->json('data'));
}
public function test_children_of_flat_event_returns_empty_list(): void
{
$event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/children");
$response->assertOk();
$this->assertCount(0, $response->json('data'));
}
// --- SHOW: festival with children ---
public function test_show_festival_contains_children(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
Event::factory()->subEvent($festival)->count(2)->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}");
$response->assertOk();
$this->assertCount(2, $response->json('data.children'));
$this->assertEquals(2, $response->json('data.children_count'));
}
// --- SHOW: sub-event with parent ---
public function test_show_sub_event_contains_parent(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
$subEvent = Event::factory()->subEvent($festival)->create();
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}");
$response->assertOk();
$this->assertEquals($festival->id, $response->json('data.parent.id'));
}
// --- Helper methods ---
public function test_is_festival_helper(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
$this->assertTrue($festival->isFestival());
$this->assertFalse($festival->isSubEvent());
}
public function test_is_sub_event_helper(): void
{
$festival = Event::factory()->festival()->create([
'organisation_id' => $this->organisation->id,
]);
$subEvent = Event::factory()->subEvent($festival)->create();
$this->assertTrue($subEvent->isSubEvent());
$this->assertFalse($subEvent->isFestival());
}
public function test_is_flat_event_helper(): void
{
$event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
]);
$this->assertTrue($event->isFlatEvent());
$this->assertFalse($event->isFestival());
$this->assertFalse($event->isSubEvent());
}
}

View File

@@ -134,6 +134,96 @@ class FestivalSectionTest extends TestCase
$this->assertSoftDeleted('festival_sections', ['id' => $section->id]);
}
public function test_update_cross_org_returns_403(): void
{
$section = FestivalSection::factory()->create(['event_id' => $this->event->id]);
Sanctum::actingAs($this->outsider);
$response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$section->id}", [
'name' => 'Hacked',
]);
$response->assertForbidden();
}
public function test_destroy_cross_org_returns_403(): void
{
$section = FestivalSection::factory()->create(['event_id' => $this->event->id]);
Sanctum::actingAs($this->outsider);
$response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$section->id}");
$response->assertForbidden();
}
public function test_store_section_with_category_and_icon(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [
'name' => 'Tapkraan',
'category' => 'Bar',
'icon' => 'tabler-beer',
'sort_order' => 0,
]);
$response->assertCreated()
->assertJson(['data' => [
'name' => 'Tapkraan',
'category' => 'Bar',
'icon' => 'tabler-beer',
]]);
$this->assertDatabaseHas('festival_sections', [
'event_id' => $this->event->id,
'name' => 'Tapkraan',
'category' => 'Bar',
'icon' => 'tabler-beer',
]);
}
public function test_section_categories_endpoint(): void
{
FestivalSection::factory()->create([
'event_id' => $this->event->id,
'category' => 'Bar',
]);
FestivalSection::factory()->create([
'event_id' => $this->event->id,
'category' => 'Podium',
]);
// Duplicate category should not appear twice
FestivalSection::factory()->create([
'event_id' => $this->event->id,
'category' => 'Bar',
]);
// Null category should not appear
FestivalSection::factory()->create([
'event_id' => $this->event->id,
'category' => null,
]);
Sanctum::actingAs($this->orgAdmin);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories");
$response->assertOk()
->assertJson(['data' => ['Bar', 'Podium']]);
$this->assertCount(2, $response->json('data'));
}
public function test_section_categories_cross_org_returns_403(): void
{
Sanctum::actingAs($this->outsider);
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories");
$response->assertForbidden();
}
public function test_reorder_updates_sort_order(): void
{
$sectionA = FestivalSection::factory()->create([
@@ -148,20 +238,17 @@ class FestivalSectionTest extends TestCase
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/events/{$this->event->id}/sections/reorder", [
'sections' => [
['id' => $sectionA->id, 'sort_order' => 2],
['id' => $sectionB->id, 'sort_order' => 1],
],
'sections' => [$sectionB->id, $sectionA->id],
]);
$response->assertOk();
$this->assertDatabaseHas('festival_sections', [
'id' => $sectionA->id,
'sort_order' => 2,
'id' => $sectionB->id,
'sort_order' => 0,
]);
$this->assertDatabaseHas('festival_sections', [
'id' => $sectionB->id,
'id' => $sectionA->id,
'sort_order' => 1,
]);
}

View File

@@ -125,6 +125,50 @@ class ShiftTest extends TestCase
$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/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/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/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([