diff --git a/api/database/seeders/ArtistTimetableDevSeeder.php b/api/database/seeders/ArtistTimetableDevSeeder.php index 0f167fc7..ccf01208 100644 --- a/api/database/seeders/ArtistTimetableDevSeeder.php +++ b/api/database/seeders/ArtistTimetableDevSeeder.php @@ -116,26 +116,29 @@ final class ArtistTimetableDevSeeder $hourSlots = [16, 17, 18, 19, 20, 21, 22, 23]; + // Per RFC §D10/§D17 + SCHEMA.md:1285+1329: + // engagement.event_id = festival (the parent — one per artist per festival) + // performance.event_id = sub-event ("show host" day) + // Same artist on multiple sub-events shares ONE engagement (D17: + // "Friday + Saturday under one combined deal = 1 engagement, 2 performances"). foreach ($subEvents as $dayIdx => $subEvent) { - // Each sub-event gets 10 scheduled engagements across the 5 stages + // Each sub-event gets 10 scheduled performances across the 5 stages for ($i = 0; $i < 10; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; $status = $statusMix[$statusIdx++ % count($statusMix)]; - // unique(artist_id, event_id): only one engagement per - // (artist, sub-event). Since we wrap the pool, skip if - // we'd violate the constraint. - $existing = ArtistEngagement::query() + // unique(artist_id, event_id) at the festival — reuse the engagement + // if the artist was already booked for this festival, adding another + // performance under it (D17 multi-perf path). + $engagement = ArtistEngagement::query() ->where('artist_id', $artist->id) - ->where('event_id', $subEvent->id) - ->exists(); - if ($existing) { - continue; + ->where('event_id', $festival->id) + ->first(); + if ($engagement === null) { + $engagement = self::createEngagement($artist, $festival, $status); + $engagements++; } - $engagement = self::createEngagement($artist, $subEvent, $status); - $engagements++; - $hour = $hourSlots[$i % count($hourSlots)]; $minute = ($i % 4) * 15; $stage = $stages->values()[$i % $stages->count()]; @@ -160,27 +163,29 @@ final class ArtistTimetableDevSeeder $u = 0; for ($i = 0; $i < 12; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; - $subEvent = $subEvents[$i % count($subEvents)]; + // Skip if this artist already has an engagement at the festival + // (e.g. via the scheduled loop above). $existing = ArtistEngagement::query() ->where('artist_id', $artist->id) - ->where('event_id', $subEvent->id) + ->where('event_id', $festival->id) ->exists(); if ($existing) { continue; } - self::createEngagement($artist, $subEvent, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]); + self::createEngagement($artist, $festival, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]); $engagements++; $unscheduled++; } // ── One parked performance (= scheduled but stage_id = null) ── - // Exercises the Session 4 "wachtrij" UI. + // Exercises the Session 4 "wachtrij" UI. Engagement at festival level; + // performance.event_id is the sub-event the parked slot belongs to. $parkedArtist = $bucket[$bucketIdx++ % $bucket->count()]; $parkedSub = $subEvents[0]; - if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $parkedSub->id)->exists()) { - $parkedEngagement = self::createEngagement($parkedArtist, $parkedSub, ArtistEngagementStatus::Confirmed); + if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $festival->id)->exists()) { + $parkedEngagement = self::createEngagement($parkedArtist, $festival, ArtistEngagementStatus::Confirmed); Performance::create([ 'engagement_id' => $parkedEngagement->id, 'event_id' => $parkedSub->id, @@ -195,17 +200,17 @@ final class ArtistTimetableDevSeeder } // ── B2B pair on Mainstage Saturday with 3-min offset (B2B detector seed) ── - // Two artists overlap on the same stage/lane within 3 minutes. + // Two artists with consecutive performances on the same stage within 3 minutes. $zaterdag = $subEvents[1]; $b2bA = $bucket[$bucketIdx++ % $bucket->count()]; $b2bB = $bucket[$bucketIdx++ % $bucket->count()]; $mainstage = $stages['Mainstage'] ?? $stages->first(); foreach ([['artist' => $b2bA, 'minute' => 30], ['artist' => $b2bB, 'minute' => 33]] as $pair) { - if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $zaterdag->id)->exists()) { + if (ArtistEngagement::query()->where('artist_id', $pair['artist']->id)->where('event_id', $festival->id)->exists()) { continue; } - $eng = self::createEngagement($pair['artist'], $zaterdag, ArtistEngagementStatus::Contracted); + $eng = self::createEngagement($pair['artist'], $festival, ArtistEngagementStatus::Contracted); self::createPerformance($eng, $zaterdag, $mainstage, 23, $pair['minute'], 60); $engagements++; $performances++; @@ -330,16 +335,22 @@ final class ArtistTimetableDevSeeder $performances = 0; $unscheduled = 0; + // Same model as the festival path: engagement at the parent series + // event, performance at the per-week sub-event. A resident artist + // playing multiple weeks gets ONE engagement with N performances. foreach ($subEvents as $weekIdx => $week) { for ($i = 0; $i < $perWeekScheduled; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; - if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $week->id)->exists()) { - continue; - } - $status = [ArtistEngagementStatus::Contracted, ArtistEngagementStatus::Confirmed, ArtistEngagementStatus::Option][$i % 3]; - $engagement = self::createEngagement($artist, $week, $status); - $engagements++; + + $engagement = ArtistEngagement::query() + ->where('artist_id', $artist->id) + ->where('event_id', $parent->id) + ->first(); + if ($engagement === null) { + $engagement = self::createEngagement($artist, $parent, $status); + $engagements++; + } $stage = $stages->values()[$i % $stages->count()]; self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60); @@ -348,11 +359,11 @@ final class ArtistTimetableDevSeeder for ($i = 0; $i < $perWeekUnscheduled; $i++) { $artist = $bucket[$bucketIdx++ % $bucket->count()]; - if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $week->id)->exists()) { + if (ArtistEngagement::query()->where('artist_id', $artist->id)->where('event_id', $parent->id)->exists()) { continue; } $status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3]; - self::createEngagement($artist, $week, $status); + self::createEngagement($artist, $parent, $status); $engagements++; $unscheduled++; } diff --git a/api/tests/Feature/Artist/TimetableSeederControllerIntegrationTest.php b/api/tests/Feature/Artist/TimetableSeederControllerIntegrationTest.php new file mode 100644 index 00000000..f7dc74f7 --- /dev/null +++ b/api/tests/Feature/Artist/TimetableSeederControllerIntegrationTest.php @@ -0,0 +1,200 @@ + */ + private array $subEvents; + + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + + $this->org = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->org->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->festival = Event::factory()->for($this->org)->festival()->create([ + 'start_date' => '2026-07-10', + 'end_date' => '2026-07-12', + ]); + $vrijdag = Event::factory()->for($this->org)->subEvent($this->festival)->create([ + 'start_date' => '2026-07-10', + 'end_date' => '2026-07-10', + ]); + $zaterdag = Event::factory()->for($this->org)->subEvent($this->festival)->create([ + 'start_date' => '2026-07-11', + 'end_date' => '2026-07-11', + ]); + $zondag = Event::factory()->for($this->org)->subEvent($this->festival)->create([ + 'start_date' => '2026-07-12', + 'end_date' => '2026-07-12', + ]); + $this->subEvents = [$vrijdag, $zaterdag, $zondag]; + + $pool = ArtistTimetableDevSeeder::seedOrganisationPool($this->org, 125); + ArtistTimetableDevSeeder::seedForFestival($this->org, $this->festival, $this->subEvents, $pool); + + Sanctum::actingAs($this->orgAdmin); + } + + public function test_engagement_event_id_is_at_festival_level(): void + { + // Direct DB invariant — engagement.event_id == festival.id for the + // entire seeded fixture. This is the canonical model from RFC §D10/§D17. + $count = \App\Models\ArtistEngagement::query() + ->where('event_id', $this->festival->id) + ->count(); + $this->assertGreaterThan(0, $count, 'No engagements found at festival level — seeder regressed to per-sub-event scoping'); + + $offModelCount = \App\Models\ArtistEngagement::query() + ->whereIn('event_id', collect($this->subEvents)->pluck('id')) + ->count(); + $this->assertSame(0, $offModelCount, 'Engagements seeded at sub-event level — Model A violated'); + } + + public function test_performance_event_id_is_at_sub_event_level(): void + { + $atFestival = \App\Models\Performance::query() + ->where('event_id', $this->festival->id) + ->count(); + $this->assertSame(0, $atFestival, 'Performances seeded at festival level — Model A violated'); + + $atSubEvents = \App\Models\Performance::query() + ->whereIn('event_id', collect($this->subEvents)->pluck('id')) + ->count(); + $this->assertGreaterThan(0, $atSubEvents); + } + + public function test_performances_index_returns_data_for_festival_with_day_filter(): void + { + // The exact request the SPA makes: GET /events/{festival}/performances?day={subevent} + foreach ($this->subEvents as $subEvent) { + $response = $this->getJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->festival->id}/performances?day={$subEvent->id}", + ); + + $response->assertOk(); + $payload = $response->json('data'); + $this->assertIsArray($payload); + $this->assertNotEmpty( + $payload, + "Performances index returned empty for festival={$this->festival->id} day={$subEvent->id} — controller engagement filter regressed", + ); + + // Every returned perf should carry event_id matching the requested ?day value + foreach ($payload as $perf) { + $this->assertSame($subEvent->id, $perf['event_id']); + } + } + } + + public function test_performances_index_unfiltered_returns_all_festival_performances(): void + { + // No ?day — should return every performance across all sub-events of the festival. + $response = $this->getJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->festival->id}/performances", + ); + + $response->assertOk(); + $payload = $response->json('data'); + $this->assertIsArray($payload); + $this->assertNotEmpty($payload); + + $subEventIds = collect($this->subEvents)->pluck('id')->all(); + foreach ($payload as $perf) { + $this->assertContains( + $perf['event_id'], + $subEventIds, + 'Returned performance has an event_id outside the festival sub-event set', + ); + } + } + + public function test_performances_index_includes_parked_when_stage_id_null_filter(): void + { + $response = $this->getJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->festival->id}/performances?stage_id=null", + ); + + $response->assertOk(); + $payload = $response->json('data'); + $this->assertNotEmpty($payload, 'Wachtrij should contain at least the seeded parked performance'); + foreach ($payload as $perf) { + $this->assertNull($perf['stage_id']); + } + } + + public function test_engagements_index_returns_seeded_engagements_for_festival(): void + { + $response = $this->getJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->festival->id}/engagements", + ); + + $response->assertOk(); + $payload = $response->json('data'); + $this->assertIsArray($payload); + $this->assertNotEmpty($payload); + + // Every returned engagement carries event_id = festival + foreach ($payload as $engagement) { + $this->assertSame($this->festival->id, $engagement['event_id']); + } + } + + public function test_stages_index_returns_seeded_stages_for_festival(): void + { + $response = $this->getJson( + "/api/v1/organisations/{$this->org->id}/events/{$this->festival->id}/stages", + ); + + $response->assertOk(); + $payload = $response->json('data'); + $this->assertIsArray($payload); + $this->assertCount(5, $payload, 'Festival fixture seeds 5 stages'); + + // Every stage carries event_id = festival + foreach ($payload as $stage) { + $this->assertSame($this->festival->id, $stage['event_id']); + } + } +}