Files
crewli/api/tests/Feature/Artist/TimetableSeederControllerIntegrationTest.php
bert.hausmans 006755ac1b 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>
2026-05-10 00:33:11 +02:00

201 lines
7.5 KiB
PHP

<?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']);
}
}
}