feat: event status state machine with transitions, prerequisites, festival cascade

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 11:16:09 +02:00
parent 4388811be9
commit 03545c570c

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Event;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Organisation;
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 EventStatusTransitionTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private Organisation $organisation;
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']);
}
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();
}
}