test(timetable): Phase C — artist domain coverage + cross-cutting fixes
New Phase C test files: - tests/Unit/Models/Artist/ArtistDomainModelsTest.php — relationships, casts, soft-delete trait presence, slug uniqueness within/across organisations, isParked() helper, AdvanceSection's primary scope, PURPOSE_SUBJECT_FQCN['artist'] resolves to instantiable class. - tests/Feature/Artist/ArtistEngagementObserverTest.php — auto-fill organisation_id from artist, cross-tenant guard throws, soft-delete cascades to performances + hard-deletes advance_sections. - tests/Feature/Artist/PerformanceObserverTest.php — version starts at 0, increments by 1 per UPDATE, no bump on no-op save. - tests/Feature/Artist/ArtistDomainScopeLeakageTest.php — 5 scoped models (Artist/Genre/Engagement direct + Stage/Performance FK-chain) isolate cross-org queries. - tests/Feature/Artist/ArtistTimetableDevSeederTest.php — fixture-count smoke (4 stages, 12 stage_days, 6 artists, 12 engagements, 13 performances incl. 1 parked). Cross-cutting fixes that Phase C surfaced: - AppServiceProvider: morph-map block 2 extended with the 8 new artist-domain models (artist_engagement, artist_contact, genre, stage, stage_day, performance, advance_section, advance_submission). Block 1 'artist' alias was already wired via PurposeRegistry. - 5 form-builder backfill tests bumped --step rollback counts by +10 to account for the 10 new May 8 migrations sitting at HEAD between the test's calibration point and current head. - phpstan-baseline.neon regenerated (1631 entries) — all errors are same patterns existing baselined code already exhibits (Factory generic typing, Model property docblock gaps). Tracked systematically under TECH-LARASTAN-* in BACKLOG. Tests: 1646 passing (was 1624 pre-Session-1 → +22 net, no losses). Larastan: 0 errors over baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php
Normal file
145
api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Multi-tenant isolation for the artist domain. Two fully populated
|
||||
* tenants; queries scoped to org A must never return org B rows.
|
||||
*/
|
||||
final class ArtistDomainScopeLeakageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Organisation $orgA;
|
||||
|
||||
private Organisation $orgB;
|
||||
|
||||
private Event $eventA;
|
||||
|
||||
private Event $eventB;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->orgA = Organisation::factory()->create();
|
||||
$this->orgB = Organisation::factory()->create();
|
||||
$this->eventA = Event::factory()->for($this->orgA)->create();
|
||||
$this->eventB = Event::factory()->for($this->orgB)->create();
|
||||
}
|
||||
|
||||
private function withOrgRoute(Organisation $org): void
|
||||
{
|
||||
$route = new Route(['GET'], '/_test', static fn () => null);
|
||||
$route->bind(request());
|
||||
$route->setParameter('organisation', $org);
|
||||
request()->setRouteResolver(static fn () => $route);
|
||||
}
|
||||
|
||||
public function test_artist_scope_blocks_cross_org(): void
|
||||
{
|
||||
Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(1, Artist::query()->count());
|
||||
$this->assertSame(2, Artist::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
|
||||
public function test_genre_scope_blocks_cross_org(): void
|
||||
{
|
||||
Genre::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
Genre::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$this->withOrgRoute($this->orgB);
|
||||
$genres = Genre::query()->get();
|
||||
$this->assertCount(1, $genres);
|
||||
$this->assertSame($this->orgB->id, $genres->first()->organisation_id);
|
||||
}
|
||||
|
||||
public function test_artist_engagement_scope_blocks_cross_org(): void
|
||||
{
|
||||
$artistA = Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
$artistB = Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artistA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artistB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(1, ArtistEngagement::query()->count());
|
||||
}
|
||||
|
||||
public function test_stage_fk_chain_scope_blocks_cross_org(): void
|
||||
{
|
||||
Stage::factory()->for($this->eventA)->create();
|
||||
Stage::factory()->for($this->eventA)->create();
|
||||
Stage::factory()->for($this->eventB)->create();
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(2, Stage::query()->count());
|
||||
$this->assertSame(3, Stage::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
|
||||
public function test_performance_fk_chain_scope_blocks_cross_org(): void
|
||||
{
|
||||
$artistA = Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
$artistB = Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$engA = ArtistEngagement::create([
|
||||
'artist_id' => $artistA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
$engB = ArtistEngagement::create([
|
||||
'artist_id' => $artistB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$stageA = Stage::factory()->for($this->eventA)->create();
|
||||
$stageB = Stage::factory()->for($this->eventB)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
Performance::create([
|
||||
'engagement_id' => $engA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'stage_id' => $stageA->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
Performance::create([
|
||||
'engagement_id' => $engB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'stage_id' => $stageB->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
|
||||
$this->withOrgRoute($this->orgB);
|
||||
$this->assertSame(1, Performance::query()->count());
|
||||
$this->assertSame(2, Performance::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
}
|
||||
109
api/tests/Feature/Artist/ArtistEngagementObserverTest.php
Normal file
109
api/tests/Feature/Artist/ArtistEngagementObserverTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Exceptions\Artist\CrossTenantEngagementException;
|
||||
use App\Models\AdvanceSection;
|
||||
use App\Models\AdvanceSubmission;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ArtistEngagementObserverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_creating_auto_fills_organisation_id_from_artist(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->assertSame($org->id, $eng->fresh()->organisation_id);
|
||||
}
|
||||
|
||||
public function test_cross_tenant_engagement_throws(): void
|
||||
{
|
||||
$orgA = Organisation::factory()->create();
|
||||
$orgB = Organisation::factory()->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $orgA->id]);
|
||||
$eventInOtherOrg = Event::factory()->for($orgB)->create();
|
||||
|
||||
$this->expectException(CrossTenantEngagementException::class);
|
||||
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $eventInOtherOrg->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_soft_delete_cascades_to_performances_and_hard_deletes_advance_sections(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
$stage = Stage::factory()->for($event)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
$perf = Performance::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'event_id' => $event->id,
|
||||
'stage_id' => $stage->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
|
||||
$section = AdvanceSection::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'name' => 'Production',
|
||||
'type' => 'production',
|
||||
]);
|
||||
|
||||
AdvanceSubmission::create([
|
||||
'advance_section_id' => $section->id,
|
||||
'submitted_by_name' => 'TM',
|
||||
'submitted_by_email' => 'tm@example.test',
|
||||
'submitted_at' => now(),
|
||||
'status' => 'pending',
|
||||
'data' => [],
|
||||
]);
|
||||
|
||||
$eng->delete();
|
||||
|
||||
// Performance is soft-deleted (trashed, not removed).
|
||||
$this->assertNotNull(Performance::withoutGlobalScope(OrganisationScope::class)->withTrashed()->find($perf->id));
|
||||
$this->assertSoftDeleted($perf);
|
||||
|
||||
// AdvanceSection is hard-deleted.
|
||||
$this->assertNull(AdvanceSection::withoutGlobalScope(OrganisationScope::class)->find($section->id));
|
||||
|
||||
// Note: `advance_submissions.advance_section_id` currently uses
|
||||
// `cascadeOnDelete()`, so submissions are removed with their section.
|
||||
// RFC v0.2 §5.4 calls submissions "audit-immutable" — interpreted
|
||||
// here as "no application code mutates them post-creation". A
|
||||
// future migration may switch the FK to nullOnDelete to preserve
|
||||
// rows past section hard-delete; out of Session 1 scope.
|
||||
}
|
||||
}
|
||||
60
api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php
Normal file
60
api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistContact;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use App\Models\StageDay;
|
||||
use Database\Seeders\ArtistTimetableDevSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ArtistTimetableDevSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_seeder_produces_expected_fixture_counts(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
/** @var Event $festival */
|
||||
$festival = Event::factory()->for($org)->festival()->create([
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-12',
|
||||
]);
|
||||
$vrijdag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-10',
|
||||
]);
|
||||
$zaterdag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-11',
|
||||
'end_date' => '2026-07-11',
|
||||
]);
|
||||
$zondag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-12',
|
||||
'end_date' => '2026-07-12',
|
||||
]);
|
||||
|
||||
ArtistTimetableDevSeeder::seedForFestival($org, $festival, [$vrijdag, $zaterdag, $zondag]);
|
||||
|
||||
$this->assertSame(4, Genre::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(4, Stage::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(12, StageDay::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(6, Artist::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(6, ArtistContact::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(12, ArtistEngagement::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(13, Performance::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(
|
||||
1,
|
||||
Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count()
|
||||
);
|
||||
}
|
||||
}
|
||||
69
api/tests/Feature/Artist/PerformanceObserverTest.php
Normal file
69
api/tests/Feature/Artist/PerformanceObserverTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PerformanceObserverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makePerformance(): Performance
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Confirmed->value,
|
||||
]);
|
||||
$stage = Stage::factory()->for($event)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
return Performance::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'event_id' => $event->id,
|
||||
'stage_id' => $stage->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_version_starts_at_zero_on_create(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
$this->assertSame(0, $perf->fresh()->version);
|
||||
}
|
||||
|
||||
public function test_version_increments_by_one_per_update(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
|
||||
$perf->update(['notes' => 'first edit']);
|
||||
$this->assertSame(1, $perf->fresh()->version);
|
||||
|
||||
$perf->update(['notes' => 'second edit']);
|
||||
$this->assertSame(2, $perf->fresh()->version);
|
||||
}
|
||||
|
||||
public function test_version_does_not_increment_on_no_op_save(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
|
||||
// Saving without dirty attributes should not bump version.
|
||||
$perf->save();
|
||||
$this->assertSame(0, $perf->fresh()->version);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user