From 03545c570c6393292b9ca8f848d7bcec25bc3205 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 11:16:09 +0200 Subject: [PATCH] feat: event status state machine with transitions, prerequisites, festival cascade Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Event/EventStatusTransitionTest.php | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 api/tests/Feature/Event/EventStatusTransitionTest.php diff --git a/api/tests/Feature/Event/EventStatusTransitionTest.php b/api/tests/Feature/Event/EventStatusTransitionTest.php new file mode 100644 index 0000000..a7f940c --- /dev/null +++ b/api/tests/Feature/Event/EventStatusTransitionTest.php @@ -0,0 +1,236 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + + $this->orgAdmin = User::factory()->create(); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + private function transitionUrl(Event $event): string + { + return "/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/transition"; + } + + // --- Valid transitions --- + + public function test_transition_draft_to_published(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'published']); + + $response->assertOk(); + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'published']); + } + + public function test_transition_published_to_registration_open(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + + // Create prerequisites: time slot + section + TimeSlot::factory()->create(['event_id' => $event->id]); + FestivalSection::factory()->create(['event_id' => $event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'registration_open']); + + $response->assertOk(); + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'registration_open']); + } + + public function test_transition_published_back_to_draft(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'draft']); + + $response->assertOk(); + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'draft']); + } + + // --- Invalid transitions --- + + public function test_transition_draft_to_showday_fails(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'showday']); + + $response->assertUnprocessable() + ->assertJsonPath('current_status', 'draft') + ->assertJsonPath('requested_status', 'showday'); + } + + public function test_transition_closed_to_anything_fails(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'closed', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'draft']); + + $response->assertUnprocessable() + ->assertJsonPath('allowed_transitions', []); + } + + // --- Prerequisite checks --- + + public function test_transition_registration_open_without_timeslots_fails(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'published', + ]); + + // Create a section but no time slot + FestivalSection::factory()->create(['event_id' => $event->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'registration_open']); + + $response->assertUnprocessable(); + $this->assertContains( + 'At least one time slot must exist before opening registration.', + $response->json('errors') + ); + } + + // --- Regular update cannot change status --- + + public function test_regular_update_cannot_change_status(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [ + 'status' => 'published', + ]); + + // Status should remain draft — the field is ignored by UpdateEventRequest + $this->assertDatabaseHas('events', ['id' => $event->id, 'status' => 'draft']); + } + + // --- Festival cascade --- + + public function test_festival_showday_cascades_to_children(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'buildup', + ]); + + $childDraft = Event::factory()->subEvent($festival)->create(['status' => 'draft']); + $childPublished = Event::factory()->subEvent($festival)->create(['status' => 'published']); + $childBuildup = Event::factory()->subEvent($festival)->create(['status' => 'buildup']); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson($this->transitionUrl($festival), ['status' => 'showday']); + + $response->assertOk(); + + // All children in earlier statuses should be cascaded to showday + $this->assertDatabaseHas('events', ['id' => $childDraft->id, 'status' => 'showday']); + $this->assertDatabaseHas('events', ['id' => $childPublished->id, 'status' => 'showday']); + $this->assertDatabaseHas('events', ['id' => $childBuildup->id, 'status' => 'showday']); + } + + // --- EventResource includes allowed_transitions --- + + public function test_allowed_transitions_in_resource(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertOk() + ->assertJsonPath('data.allowed_transitions', ['published']); + } + + // --- Auth --- + + public function test_unauthenticated_user_cannot_transition(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'published']); + + $response->assertUnauthorized(); + } + + public function test_outsider_cannot_transition(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'draft', + ]); + + $outsider = User::factory()->create(); + Sanctum::actingAs($outsider); + + $response = $this->postJson($this->transitionUrl($event), ['status' => 'published']); + + $response->assertForbidden(); + } +}