From e43dd60756b0b90c38df676e9a42886ad0d1e492 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 8 May 2026 19:15:38 +0200 Subject: [PATCH] =?UTF-8?q?test(timetable):=20Phase=20C=20=E2=80=94=20arti?= =?UTF-8?q?st=20domain=20coverage=20+=20cross-cutting=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/app/Providers/AppServiceProvider.php | 11 + api/phpstan-baseline.neon | 354 ++++++++++++++++++ .../Artist/ArtistDomainScopeLeakageTest.php | 145 +++++++ .../Artist/ArtistEngagementObserverTest.php | 109 ++++++ .../Artist/ArtistTimetableDevSeederTest.php | 60 +++ .../Artist/PerformanceObserverTest.php | 69 ++++ .../FormFieldBindingMigrationTest.php | 6 +- .../ConditionalLogicBackfillTest.php | 8 +- .../FormFieldConfigBackfillAndDropTest.php | 2 +- .../Options/FormFieldOptionsBackfillTest.php | 20 +- .../FormFieldValidationRuleBackfillTest.php | 14 +- .../Models/Artist/ArtistDomainModelsTest.php | 169 +++++++++ 12 files changed, 942 insertions(+), 25 deletions(-) create mode 100644 api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php create mode 100644 api/tests/Feature/Artist/ArtistEngagementObserverTest.php create mode 100644 api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php create mode 100644 api/tests/Feature/Artist/PerformanceObserverTest.php create mode 100644 api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 98a1e653..04217e5e 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -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, diff --git a/api/phpstan-baseline.neon b/api/phpstan-baseline.neon index 0478310e..c08a06a2 100644 --- a/api/phpstan-baseline.neon +++ b/api/phpstan-baseline.neon @@ -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\\) of method Database\\Factories\\AdvanceSectionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/AdvanceSectionFactory.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\AdvanceSubmissionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/AdvanceSubmissionFactory.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\ArtistContactFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/ArtistContactFactory.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\ArtistEngagementFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/ArtistEngagementFactory.php + + - + message: '#^Return type \(array\\) of method Database\\Factories\\ArtistFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/ArtistFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\CompanyFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -6528,6 +6858,12 @@ parameters: count: 1 path: database/factories/FormBuilder/FormWebhookDeliveryFactory.php + - + message: '#^Return type \(array\\) of method Database\\Factories\\GenreFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/GenreFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\ImpersonationSessionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -6546,6 +6882,12 @@ parameters: count: 1 path: database/factories/OrganisationFactory.php + - + message: '#^Return type \(array\\) of method Database\\Factories\\PerformanceFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/PerformanceFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\PersonFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -6588,6 +6930,12 @@ parameters: count: 1 path: database/factories/ShiftFactory.php + - + message: '#^Return type \(array\\) of method Database\\Factories\\StageFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' + identifier: method.childReturnType + count: 1 + path: database/factories/StageFactory.php + - message: '#^Return type \(array\\) of method Database\\Factories\\TimeSlotFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\: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 diff --git a/api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php b/api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php new file mode 100644 index 00000000..b967b43d --- /dev/null +++ b/api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php @@ -0,0 +1,145 @@ +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()); + } +} diff --git a/api/tests/Feature/Artist/ArtistEngagementObserverTest.php b/api/tests/Feature/Artist/ArtistEngagementObserverTest.php new file mode 100644 index 00000000..b93a9749 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistEngagementObserverTest.php @@ -0,0 +1,109 @@ +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. + } +} diff --git a/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php new file mode 100644 index 00000000..184122f8 --- /dev/null +++ b/api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php @@ -0,0 +1,60 @@ +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() + ); + } +} diff --git a/api/tests/Feature/Artist/PerformanceObserverTest.php b/api/tests/Feature/Artist/PerformanceObserverTest.php new file mode 100644 index 00000000..95723450 --- /dev/null +++ b/api/tests/Feature/Artist/PerformanceObserverTest.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index f797018c..85cda8a6 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -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')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index d6db383a..ab510d94 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -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']]], diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index 0490105a..cd85c869 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -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([ diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 173c685c..b16d2e81 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -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(), diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 29b86f6a..6d60d3dc 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -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(); diff --git a/api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php b/api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php new file mode 100644 index 00000000..c3994b74 --- /dev/null +++ b/api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php @@ -0,0 +1,169 @@ +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 $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']); + } +}