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:
2026-05-08 19:15:38 +02:00
parent 64878f2734
commit e43dd60756
12 changed files with 942 additions and 25 deletions

View File

@@ -323,6 +323,17 @@ class AppServiceProvider extends ServiceProvider
'user_organisation_tag' => UserOrganisationTag::class,
'volunteer_availability' => VolunteerAvailability::class,
// RFC-TIMETABLE v0.2 artist-domain models (Session 1). Artist
// itself is in Block 1 via PurposeRegistry.
'artist_engagement' => \App\Models\ArtistEngagement::class,
'artist_contact' => \App\Models\ArtistContact::class,
'genre' => \App\Models\Genre::class,
'stage' => \App\Models\Stage::class,
'stage_day' => \App\Models\StageDay::class,
'performance' => \App\Models\Performance::class,
'advance_section' => \App\Models\AdvanceSection::class,
'advance_submission' => \App\Models\AdvanceSubmission::class,
// Form-builder models used as activity-log subjects and (S2+)
// polymorphic webhook payload subjects.
'form_schema' => FormSchema::class,

View File

@@ -1,5 +1,17 @@
parameters:
ignoreErrors:
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 2
path: app/Exceptions/Artist/CrossTenantEngagementException.php
-
message: '#^Using nullsafe property access "\?\-\>organisation_id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 2
path: app/Exceptions/Artist/CrossTenantEngagementException.php
-
message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -3414,12 +3426,156 @@ parameters:
count: 1
path: app/Mail/TransactionalMail.php
-
message: '#^Class App\\Models\\AdvanceSection uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSection.php
-
message: '#^Method App\\Models\\AdvanceSection\:\:engagement\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSection.php
-
message: '#^Method App\\Models\\AdvanceSection\:\:submissions\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSection.php
-
message: '#^Class App\\Models\\AdvanceSubmission uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSubmission.php
-
message: '#^Method App\\Models\\AdvanceSubmission\:\:reviewer\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSubmission.php
-
message: '#^Method App\\Models\\AdvanceSubmission\:\:section\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/AdvanceSubmission.php
-
message: '#^Class App\\Models\\Artist uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Method App\\Models\\Artist\:\:agentCompany\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Method App\\Models\\Artist\:\:contacts\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Method App\\Models\\Artist\:\:defaultGenre\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Method App\\Models\\Artist\:\:engagements\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Method App\\Models\\Artist\:\:organisation\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Artist.php
-
message: '#^Class App\\Models\\ArtistContact uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistContact.php
-
message: '#^Method App\\Models\\ArtistContact\:\:artist\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistContact.php
-
message: '#^Method App\\Models\\ArtistContact\:\:scopePrimary\(\) has parameter \$query with generic class Illuminate\\Database\\Eloquent\\Builder but does not specify its types\: TModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistContact.php
-
message: '#^Method App\\Models\\ArtistContact\:\:scopePrimary\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistContact.php
-
message: '#^Class App\\Models\\ArtistEngagement uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:advanceSections\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:artist\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:event\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:organisation\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:performances\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Method App\\Models\\ArtistEngagement\:\:projectLeader\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/ArtistEngagement.php
-
message: '#^Class App\\Models\\Company uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/Company.php
-
message: '#^Method App\\Models\\Company\:\:artistsAsAgent\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Company.php
-
message: '#^Method App\\Models\\Company\:\:organisation\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -3564,6 +3720,12 @@ parameters:
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:artistEngagements\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:children\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -3618,6 +3780,12 @@ parameters:
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:performances\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:persons\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -3732,6 +3900,12 @@ parameters:
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:stages\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Event.php
-
message: '#^Method App\\Models\\Event\:\:timeSlots\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -4272,6 +4446,24 @@ parameters:
count: 1
path: app/Models/FormBuilder/FormWebhookDelivery.php
-
message: '#^Class App\\Models\\Genre uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/Genre.php
-
message: '#^Method App\\Models\\Genre\:\:artists\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Genre.php
-
message: '#^Method App\\Models\\Genre\:\:organisation\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Genre.php
-
message: '#^Class App\\Models\\ImpersonationSession uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4356,6 +4548,18 @@ parameters:
count: 1
path: app/Models/Organisation.php
-
message: '#^Method App\\Models\\Organisation\:\:artistEngagements\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Organisation.php
-
message: '#^Method App\\Models\\Organisation\:\:artists\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Organisation.php
-
message: '#^Method App\\Models\\Organisation\:\:companies\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -4392,6 +4596,12 @@ parameters:
count: 1
path: app/Models/Organisation.php
-
message: '#^Method App\\Models\\Organisation\:\:genres\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Organisation.php
-
message: '#^Method App\\Models\\Organisation\:\:invitations\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
@@ -4434,6 +4644,30 @@ parameters:
count: 1
path: app/Models/OrganisationEmailTemplate.php
-
message: '#^Class App\\Models\\Performance uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/Performance.php
-
message: '#^Method App\\Models\\Performance\:\:engagement\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Performance.php
-
message: '#^Method App\\Models\\Performance\:\:event\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Performance.php
-
message: '#^Method App\\Models\\Performance\:\:stage\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Performance.php
-
message: '#^Class App\\Models\\Person uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -4920,6 +5154,60 @@ parameters:
count: 1
path: app/Models/ShiftWaitlist.php
-
message: '#^Class App\\Models\\Stage uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Method App\\Models\\Stage\:\:event\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Method App\\Models\\Stage\:\:performances\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Method App\\Models\\Stage\:\:scopeOrdered\(\) has parameter \$query with generic class Illuminate\\Database\\Eloquent\\Builder but does not specify its types\: TModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Method App\\Models\\Stage\:\:scopeOrdered\(\) return type with generic class Illuminate\\Database\\Eloquent\\Builder does not specify its types\: TModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Method App\\Models\\Stage\:\:stageDays\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\HasMany does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/Stage.php
-
message: '#^Class App\\Models\\StageDay uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
count: 1
path: app/Models/StageDay.php
-
message: '#^Method App\\Models\\StageDay\:\:event\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/StageDay.php
-
message: '#^Method App\\Models\\StageDay\:\:stage\(\) return type with generic class Illuminate\\Database\\Eloquent\\Relations\\BelongsTo does not specify its types\: TRelatedModel, TDeclaringModel$#'
identifier: missingType.generics
count: 1
path: app/Models/StageDay.php
-
message: '#^Class App\\Models\\TimeSlot uses generic trait Illuminate\\Database\\Eloquent\\Factories\\HasFactory but does not specify its types\: TFactory$#'
identifier: missingType.generics
@@ -5184,6 +5472,18 @@ parameters:
count: 1
path: app/Notifications/ResetPasswordNotification.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$organisation_id\.$#'
identifier: property.notFound
count: 3
path: app/Observers/ArtistEngagementObserver.php
-
message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: app/Observers/ArtistEngagementObserver.php
-
message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
identifier: identical.alwaysFalse
@@ -6378,6 +6678,36 @@ parameters:
count: 1
path: app/Services/ShiftAssignmentService.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\AdvanceSectionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\AdvanceSection, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\AdvanceSection\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/AdvanceSectionFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\AdvanceSubmissionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\AdvanceSubmission, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\AdvanceSubmission\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/AdvanceSubmissionFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ArtistContactFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\ArtistContact, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\ArtistContact\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/ArtistContactFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ArtistEngagementFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\ArtistEngagement, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\ArtistEngagement\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/ArtistEngagementFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ArtistFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Artist, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Artist\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/ArtistFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\CompanyFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Company, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Company\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -6528,6 +6858,12 @@ parameters:
count: 1
path: database/factories/FormBuilder/FormWebhookDeliveryFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\GenreFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Genre, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Genre\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/GenreFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ImpersonationSessionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\ImpersonationSession, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\ImpersonationSession\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -6546,6 +6882,12 @@ parameters:
count: 1
path: database/factories/OrganisationFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\PerformanceFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Performance, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Performance\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/PerformanceFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\PersonFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Person, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Person\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -6588,6 +6930,12 @@ parameters:
count: 1
path: database/factories/ShiftFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\StageFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Stage, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Stage\>\:\:definition\(\)$#'
identifier: method.childReturnType
count: 1
path: database/factories/StageFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\TimeSlotFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\TimeSlot, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\TimeSlot\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -7452,6 +7800,12 @@ parameters:
count: 3
path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound
count: 4
path: tests/Unit/Models/Artist/ArtistDomainModelsTest.php
-
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
identifier: property.notFound

View 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());
}
}

View 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.
}
}

View 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()
);
}
}

View 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);
}
}

View File

@@ -58,7 +58,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// WS-6 migrations (action-failures, apply-status, retry-attempts,
// exception-trace, failure-response-code [v1.3-delta D1]) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings).
$this->artisan('migrate:rollback', ['--step' => 22])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 32])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
@@ -121,7 +121,7 @@ final class FormFieldBindingMigrationTest extends TestCase
{
// Walk back the full WS-5d + WS-5c + WS-6 (incl. v1.3-delta D1
// failure_response_code) + WS-5b + WS-5a stack.
$this->artisan('migrate:rollback', ['--step' => 22])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 32])->assertSuccessful();
[$fieldAId] = $this->seedFieldsWithBindingJson();
[$libAId] = $this->seedLibraryWithBindingJson();
@@ -137,7 +137,7 @@ final class FormFieldBindingMigrationTest extends TestCase
// go → restores the pre-WS-5b state (conditional-logic, validation-rules,
// configs and options tables gone, validation_rules + options JSON
// columns reappear on source tables; binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));

View File

@@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase
// create-options + WS-5c drop-cl-col + WS-5c backfill-cl
// migrations to land in the conditional-logic JSON-era state with
// no relational form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
@@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
@@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
@@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],

View File

@@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase
// Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b
// migrations = 11, to get the pre-WS-5b state where the JSON column
// still exists on form_fields / form_field_library.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 27])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$fieldId = $this->seedField([

View File

@@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Roll back only the backfill migration (latest WS-5d step).
// Leaves the form_field_options table in place, JSON columns
// present on the source tables, and snapshots in pre-WS-5d shape.
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertTrue(Schema::hasTable('form_field_options'));
$this->assertTrue(Schema::hasColumn('form_fields', 'options'));
@@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_rollback_reconstructs_json_columns_and_snapshots(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
[$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId);
@@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
// Step back over only the backfill migration → JSON columns repopulate
// and snapshots revert to flat-string-array shape.
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertSame(0, DB::table('form_field_options')->count());
$select = DB::table('form_fields')->where('id', $selectId)->first();
@@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']);
$this->expectException(\RuntimeException::class);
@@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_contains_non_string_entry(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
['label' => 'A'],
@@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_options_is_object_shape(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
'XS' => 'Extra small',
@@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_translations_length_mismatch(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([
'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3
]));
@@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_non_string_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([
'de' => ['options' => ['Klein', 42]],
]));
@@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_on_oversized_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([
'de' => ['options' => [str_repeat('x', 256)]],
]));
@@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase
public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->seedTemplateWithSnapshotRaw([
'fields' => [[
'id' => (string) Str::ulid(),

View File

@@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// validation-rules-backfill + create-validation-rules) = 14.
// Brings us to the pre-WS-5b state: validation_rules JSON column
// present, no relational tables for WS-5b/c/d.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
@@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TAG_PICKER',
@@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'BOOLEAN',
@@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// full-back-then-full-forward cycle — rolling back all WS-5b
// migrations restores the pre-WS-5b state (columns present on
// source tables; validation rules relational table gone).
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
[$numberId] = $this->seedFields();
$this->artisan('migrate')->assertSuccessful();
@@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// Roll back WS-5b fully → column reappears and carries canonical JSON
// reconstructed from the relational rows.
$this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 30])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$field = DB::table('form_fields')->where('id', $numberId)->first();

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Models\Artist;
use App\Enums\Artist\ArtistEngagementStatus;
use App\Enums\Artist\BumaHandledBy;
use App\Models\AdvanceSection;
use App\Models\AdvanceSubmission;
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 Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ArtistDomainModelsTest extends TestCase
{
use RefreshDatabase;
private function artistInOrg(Organisation $org): Artist
{
return Artist::factory()->create(['organisation_id' => $org->id]);
}
public function test_artist_uses_soft_deletes_trait(): void
{
$this->assertContains(SoftDeletes::class, class_uses_recursive(Artist::class));
$this->assertContains(SoftDeletes::class, class_uses_recursive(ArtistEngagement::class));
$this->assertContains(SoftDeletes::class, class_uses_recursive(Performance::class));
}
public function test_genre_stage_stagemay_advance_models_do_not_use_soft_deletes(): void
{
$noSoftDelete = [Genre::class, Stage::class, StageDay::class, AdvanceSection::class, AdvanceSubmission::class, ArtistContact::class];
foreach ($noSoftDelete as $cls) {
$this->assertNotContains(SoftDeletes::class, class_uses_recursive($cls), "{$cls} should not soft-delete");
}
}
public function test_artist_belongs_to_organisation_genre_and_agent(): void
{
$org = Organisation::factory()->create();
$genre = Genre::factory()->create(['organisation_id' => $org->id]);
$artist = Artist::factory()->withGenre($genre)->create(['organisation_id' => $org->id]);
$this->assertSame($org->id, $artist->organisation->id);
$this->assertSame($genre->id, $artist->defaultGenre->id);
}
public function test_artist_slug_collisions_within_org_get_numeric_suffix(): void
{
$org = Organisation::factory()->create();
$a = Artist::create(['organisation_id' => $org->id, 'name' => 'Same Name']);
$b = Artist::create(['organisation_id' => $org->id, 'name' => 'Same Name']);
$c = Artist::create(['organisation_id' => $org->id, 'name' => 'Same Name']);
$this->assertSame('same-name', $a->slug);
$this->assertSame('same-name-2', $b->slug);
$this->assertSame('same-name-3', $c->slug);
}
public function test_artist_slug_can_repeat_across_organisations(): void
{
$orgA = Organisation::factory()->create();
$orgB = Organisation::factory()->create();
$a = Artist::create(['organisation_id' => $orgA->id, 'name' => 'Shared Name']);
$b = Artist::create(['organisation_id' => $orgB->id, 'name' => 'Shared Name']);
$this->assertSame('shared-name', $a->slug);
$this->assertSame('shared-name', $b->slug);
}
public function test_engagement_casts_enums(): void
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = $this->artistInOrg($org);
$eng = ArtistEngagement::create([
'artist_id' => $artist->id,
'event_id' => $event->id,
'booking_status' => ArtistEngagementStatus::Confirmed->value,
'buma_handled_by' => BumaHandledBy::BookingAgency->value,
]);
$this->assertInstanceOf(ArtistEngagementStatus::class, $eng->fresh()->booking_status);
$this->assertSame(ArtistEngagementStatus::Confirmed, $eng->fresh()->booking_status);
$this->assertSame(BumaHandledBy::BookingAgency, $eng->fresh()->buma_handled_by);
}
public function test_performance_is_parked_when_stage_id_null(): void
{
$perf = new Performance(['stage_id' => null]);
$this->assertTrue($perf->isParked());
$perfWithStage = new Performance(['stage_id' => '01ABCDEFGHIJKLMNOPQRSTUVWX']);
$this->assertFalse($perfWithStage->isParked());
}
public function test_engagement_relationships(): void
{
$org = Organisation::factory()->create();
$event = Event::factory()->for($org)->create();
$artist = $this->artistInOrg($org);
$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();
Performance::create([
'engagement_id' => $eng->id,
'event_id' => $event->id,
'stage_id' => $stage->id,
'start_at' => $start,
'end_at' => $start->addHour(),
]);
$this->assertSame(1, $eng->performances()->count());
$this->assertSame($artist->id, $eng->artist->id);
$this->assertSame($event->id, $eng->event->id);
}
public function test_artist_contact_primary_scope(): void
{
$org = Organisation::factory()->create();
$artist = $this->artistInOrg($org);
ArtistContact::factory()->for($artist)->create(['is_primary' => false]);
ArtistContact::factory()->for($artist)->primary()->create();
$primary = ArtistContact::query()
->withoutGlobalScope(OrganisationScope::class)
->primary()
->get();
$this->assertCount(1, $primary);
$this->assertTrue($primary->first()->is_primary);
}
public function test_purpose_subject_fqcn_artist_resolves_to_instantiable_class(): void
{
$reflection = new \ReflectionClass(\App\Providers\AppServiceProvider::class);
$constant = $reflection->getReflectionConstant('PURPOSE_SUBJECT_FQCN');
$this->assertNotFalse($constant);
/** @var array<string, class-string> $map */
$map = $constant->getValue();
$this->assertArrayHasKey('artist', $map);
$this->assertSame(Artist::class, $map['artist']);
$this->assertTrue(class_exists($map['artist']));
$this->assertInstanceOf(Artist::class, new $map['artist']);
}
}