fix(seeder): align ArtistTimetableDevSeeder to canonical engagement-vs-performance model (B1)
Phase A diagnosed an empty SPA timetable as a controller filter bug. B1.1's
schema-verify gate proved the opposite: the seeder violates Model A, the
controllers are correct.
Canonical model (Model A) per:
- dev-docs/SCHEMA.md:1285 artist_engagements.event_id → festival OR flat event
- dev-docs/SCHEMA.md:1329 performances.event_id → sub-event OR flat event ("show host")
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:1247-1257 (§10.2 contract)
"performance.event_id must be flat event OR a sub-event of the
engagement.event_id festival"
- dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md:455-477 (§D17)
"Friday + Saturday under one combined deal = 1 engagement, 2 performances"
— only works if engagement is at festival level
Controller audit (B1.2): all five filters in
api/app/Http/Controllers/Api/V1/Artist/{PerformanceController,
ArtistEngagementController, StageController}.php already match Model A.
No controller changes needed.
Seeder change (B1.3) — single consistent fix:
ArtistTimetableDevSeeder::seedForFestival now creates one engagement per
(artist, festival) instead of per (artist, sub-event). When the same artist
recurs across iterations on different sub-events, the existing engagement
is reused and another performance is added (the D17 multi-perf path).
Performances continue to carry event_id = sub-event.
Same model fix in seedForSeries (engagement at parent series, performance
at week sub-event).
seedForFlatEvent already conformed (engagement.event_id = performance.event_id
= the flat event itself).
Existence-check semantics shift from `where event_id = $subEvent->id` to
`where event_id = $festival->id` (or $parent->id for series). Numerically
the test counts hold because the bucket-cycling makes scheduled artists
distinct within the festival window.
Tests (B1.4) — new TimetableSeederControllerIntegrationTest with 7 assertions:
- engagement.event_id is at festival level (DB invariant)
- performance.event_id is at sub-event level (DB invariant)
- GET /performances?day={subEvent} returns non-empty + correct event_ids
- GET /performances unfiltered returns all sub-event performances
- GET /performances?stage_id=null returns the seeded parked perf
- GET /engagements returns engagements with event_id = festival
- GET /stages returns 5 stages with event_id = festival
This locks the visible-symptom regression from Session 4: an empty SPA
timetable on a freshly-seeded festival cannot land again silently.
Existing ArtistTimetableDevSeederTest (4 tests) and the broader Artist
suite (121 tests) all stay green. composer analyse + Pint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,26 +116,29 @@ final class ArtistTimetableDevSeeder
|
|||||||
|
|
||||||
$hourSlots = [16, 17, 18, 19, 20, 21, 22, 23];
|
$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) {
|
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++) {
|
for ($i = 0; $i < 10; $i++) {
|
||||||
$artist = $bucket[$bucketIdx++ % $bucket->count()];
|
$artist = $bucket[$bucketIdx++ % $bucket->count()];
|
||||||
$status = $statusMix[$statusIdx++ % count($statusMix)];
|
$status = $statusMix[$statusIdx++ % count($statusMix)];
|
||||||
|
|
||||||
// unique(artist_id, event_id): only one engagement per
|
// unique(artist_id, event_id) at the festival — reuse the engagement
|
||||||
// (artist, sub-event). Since we wrap the pool, skip if
|
// if the artist was already booked for this festival, adding another
|
||||||
// we'd violate the constraint.
|
// performance under it (D17 multi-perf path).
|
||||||
$existing = ArtistEngagement::query()
|
$engagement = ArtistEngagement::query()
|
||||||
->where('artist_id', $artist->id)
|
->where('artist_id', $artist->id)
|
||||||
->where('event_id', $subEvent->id)
|
->where('event_id', $festival->id)
|
||||||
->exists();
|
->first();
|
||||||
if ($existing) {
|
if ($engagement === null) {
|
||||||
continue;
|
$engagement = self::createEngagement($artist, $festival, $status);
|
||||||
|
$engagements++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$engagement = self::createEngagement($artist, $subEvent, $status);
|
|
||||||
$engagements++;
|
|
||||||
|
|
||||||
$hour = $hourSlots[$i % count($hourSlots)];
|
$hour = $hourSlots[$i % count($hourSlots)];
|
||||||
$minute = ($i % 4) * 15;
|
$minute = ($i % 4) * 15;
|
||||||
$stage = $stages->values()[$i % $stages->count()];
|
$stage = $stages->values()[$i % $stages->count()];
|
||||||
@@ -160,27 +163,29 @@ final class ArtistTimetableDevSeeder
|
|||||||
$u = 0;
|
$u = 0;
|
||||||
for ($i = 0; $i < 12; $i++) {
|
for ($i = 0; $i < 12; $i++) {
|
||||||
$artist = $bucket[$bucketIdx++ % $bucket->count()];
|
$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()
|
$existing = ArtistEngagement::query()
|
||||||
->where('artist_id', $artist->id)
|
->where('artist_id', $artist->id)
|
||||||
->where('event_id', $subEvent->id)
|
->where('event_id', $festival->id)
|
||||||
->exists();
|
->exists();
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::createEngagement($artist, $subEvent, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]);
|
self::createEngagement($artist, $festival, $unscheduledStatusMix[$u++ % count($unscheduledStatusMix)]);
|
||||||
$engagements++;
|
$engagements++;
|
||||||
$unscheduled++;
|
$unscheduled++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── One parked performance (= scheduled but stage_id = null) ──
|
// ── 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()];
|
$parkedArtist = $bucket[$bucketIdx++ % $bucket->count()];
|
||||||
$parkedSub = $subEvents[0];
|
$parkedSub = $subEvents[0];
|
||||||
if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $parkedSub->id)->exists()) {
|
if (! ArtistEngagement::query()->where('artist_id', $parkedArtist->id)->where('event_id', $festival->id)->exists()) {
|
||||||
$parkedEngagement = self::createEngagement($parkedArtist, $parkedSub, ArtistEngagementStatus::Confirmed);
|
$parkedEngagement = self::createEngagement($parkedArtist, $festival, ArtistEngagementStatus::Confirmed);
|
||||||
Performance::create([
|
Performance::create([
|
||||||
'engagement_id' => $parkedEngagement->id,
|
'engagement_id' => $parkedEngagement->id,
|
||||||
'event_id' => $parkedSub->id,
|
'event_id' => $parkedSub->id,
|
||||||
@@ -195,17 +200,17 @@ final class ArtistTimetableDevSeeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── B2B pair on Mainstage Saturday with 3-min offset (B2B detector seed) ──
|
// ── 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];
|
$zaterdag = $subEvents[1];
|
||||||
$b2bA = $bucket[$bucketIdx++ % $bucket->count()];
|
$b2bA = $bucket[$bucketIdx++ % $bucket->count()];
|
||||||
$b2bB = $bucket[$bucketIdx++ % $bucket->count()];
|
$b2bB = $bucket[$bucketIdx++ % $bucket->count()];
|
||||||
$mainstage = $stages['Mainstage'] ?? $stages->first();
|
$mainstage = $stages['Mainstage'] ?? $stages->first();
|
||||||
|
|
||||||
foreach ([['artist' => $b2bA, 'minute' => 30], ['artist' => $b2bB, 'minute' => 33]] as $pair) {
|
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;
|
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);
|
self::createPerformance($eng, $zaterdag, $mainstage, 23, $pair['minute'], 60);
|
||||||
$engagements++;
|
$engagements++;
|
||||||
$performances++;
|
$performances++;
|
||||||
@@ -330,16 +335,22 @@ final class ArtistTimetableDevSeeder
|
|||||||
$performances = 0;
|
$performances = 0;
|
||||||
$unscheduled = 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) {
|
foreach ($subEvents as $weekIdx => $week) {
|
||||||
for ($i = 0; $i < $perWeekScheduled; $i++) {
|
for ($i = 0; $i < $perWeekScheduled; $i++) {
|
||||||
$artist = $bucket[$bucketIdx++ % $bucket->count()];
|
$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];
|
$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()];
|
$stage = $stages->values()[$i % $stages->count()];
|
||||||
self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60);
|
self::createPerformance($engagement, $week, $stage, 14 + $i * 2, 0, 60);
|
||||||
@@ -348,11 +359,11 @@ final class ArtistTimetableDevSeeder
|
|||||||
|
|
||||||
for ($i = 0; $i < $perWeekUnscheduled; $i++) {
|
for ($i = 0; $i < $perWeekUnscheduled; $i++) {
|
||||||
$artist = $bucket[$bucketIdx++ % $bucket->count()];
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
$status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3];
|
$status = [ArtistEngagementStatus::Draft, ArtistEngagementStatus::Requested, ArtistEngagementStatus::Option][$i % 3];
|
||||||
self::createEngagement($artist, $week, $status);
|
self::createEngagement($artist, $parent, $status);
|
||||||
$engagements++;
|
$engagements++;
|
||||||
$unscheduled++;
|
$unscheduled++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Artist;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\ArtistTimetableDevSeeder;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the model contract (RFC §D10/§D17, SCHEMA.md:1285+1329):
|
||||||
|
*
|
||||||
|
* engagement.event_id = festival or flat event
|
||||||
|
* performance.event_id = sub-event or flat event ("show host")
|
||||||
|
*
|
||||||
|
* Without this test, the Session-4 visible-symptom regression ("API returns
|
||||||
|
* empty for a festival even when seeded") would slip through again because
|
||||||
|
* the existing controller tests build fixtures with factories that put
|
||||||
|
* engagement.event_id at the same level as performance.event_id, hiding
|
||||||
|
* the festival-vs-sub-event scoping.
|
||||||
|
*
|
||||||
|
* What we exercise: the actual `ArtistTimetableDevSeeder` against the actual
|
||||||
|
* `PerformanceController::index`, `ArtistEngagementController::index`, and
|
||||||
|
* `StageController::index`. Both `?day=` and unfiltered festival-level
|
||||||
|
* requests, so the controller's whereHas('engagement', …) at festival level
|
||||||
|
* AND the ?day filter on performance.event_id are both proved.
|
||||||
|
*/
|
||||||
|
final class TimetableSeederControllerIntegrationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $org;
|
||||||
|
|
||||||
|
private User $orgAdmin;
|
||||||
|
|
||||||
|
private Event $festival;
|
||||||
|
|
||||||
|
/** @var array<int, Event> */
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user