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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
145
api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php
Normal file
145
api/tests/Feature/Artist/ArtistDomainScopeLeakageTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Multi-tenant isolation for the artist domain. Two fully populated
|
||||
* tenants; queries scoped to org A must never return org B rows.
|
||||
*/
|
||||
final class ArtistDomainScopeLeakageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Organisation $orgA;
|
||||
|
||||
private Organisation $orgB;
|
||||
|
||||
private Event $eventA;
|
||||
|
||||
private Event $eventB;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->orgA = Organisation::factory()->create();
|
||||
$this->orgB = Organisation::factory()->create();
|
||||
$this->eventA = Event::factory()->for($this->orgA)->create();
|
||||
$this->eventB = Event::factory()->for($this->orgB)->create();
|
||||
}
|
||||
|
||||
private function withOrgRoute(Organisation $org): void
|
||||
{
|
||||
$route = new Route(['GET'], '/_test', static fn () => null);
|
||||
$route->bind(request());
|
||||
$route->setParameter('organisation', $org);
|
||||
request()->setRouteResolver(static fn () => $route);
|
||||
}
|
||||
|
||||
public function test_artist_scope_blocks_cross_org(): void
|
||||
{
|
||||
Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(1, Artist::query()->count());
|
||||
$this->assertSame(2, Artist::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
|
||||
public function test_genre_scope_blocks_cross_org(): void
|
||||
{
|
||||
Genre::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
Genre::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$this->withOrgRoute($this->orgB);
|
||||
$genres = Genre::query()->get();
|
||||
$this->assertCount(1, $genres);
|
||||
$this->assertSame($this->orgB->id, $genres->first()->organisation_id);
|
||||
}
|
||||
|
||||
public function test_artist_engagement_scope_blocks_cross_org(): void
|
||||
{
|
||||
$artistA = Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
$artistB = Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artistA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artistB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(1, ArtistEngagement::query()->count());
|
||||
}
|
||||
|
||||
public function test_stage_fk_chain_scope_blocks_cross_org(): void
|
||||
{
|
||||
Stage::factory()->for($this->eventA)->create();
|
||||
Stage::factory()->for($this->eventA)->create();
|
||||
Stage::factory()->for($this->eventB)->create();
|
||||
|
||||
$this->withOrgRoute($this->orgA);
|
||||
$this->assertSame(2, Stage::query()->count());
|
||||
$this->assertSame(3, Stage::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
|
||||
public function test_performance_fk_chain_scope_blocks_cross_org(): void
|
||||
{
|
||||
$artistA = Artist::factory()->create(['organisation_id' => $this->orgA->id]);
|
||||
$artistB = Artist::factory()->create(['organisation_id' => $this->orgB->id]);
|
||||
|
||||
$engA = ArtistEngagement::create([
|
||||
'artist_id' => $artistA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
$engB = ArtistEngagement::create([
|
||||
'artist_id' => $artistB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$stageA = Stage::factory()->for($this->eventA)->create();
|
||||
$stageB = Stage::factory()->for($this->eventB)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
Performance::create([
|
||||
'engagement_id' => $engA->id,
|
||||
'event_id' => $this->eventA->id,
|
||||
'stage_id' => $stageA->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
Performance::create([
|
||||
'engagement_id' => $engB->id,
|
||||
'event_id' => $this->eventB->id,
|
||||
'stage_id' => $stageB->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
|
||||
$this->withOrgRoute($this->orgB);
|
||||
$this->assertSame(1, Performance::query()->count());
|
||||
$this->assertSame(2, Performance::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
}
|
||||
}
|
||||
109
api/tests/Feature/Artist/ArtistEngagementObserverTest.php
Normal file
109
api/tests/Feature/Artist/ArtistEngagementObserverTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Exceptions\Artist\CrossTenantEngagementException;
|
||||
use App\Models\AdvanceSection;
|
||||
use App\Models\AdvanceSubmission;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ArtistEngagementObserverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_creating_auto_fills_organisation_id_from_artist(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
|
||||
$this->assertSame($org->id, $eng->fresh()->organisation_id);
|
||||
}
|
||||
|
||||
public function test_cross_tenant_engagement_throws(): void
|
||||
{
|
||||
$orgA = Organisation::factory()->create();
|
||||
$orgB = Organisation::factory()->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $orgA->id]);
|
||||
$eventInOtherOrg = Event::factory()->for($orgB)->create();
|
||||
|
||||
$this->expectException(CrossTenantEngagementException::class);
|
||||
|
||||
ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $eventInOtherOrg->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_soft_delete_cascades_to_performances_and_hard_deletes_advance_sections(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Draft->value,
|
||||
]);
|
||||
$stage = Stage::factory()->for($event)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
$perf = Performance::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'event_id' => $event->id,
|
||||
'stage_id' => $stage->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
|
||||
$section = AdvanceSection::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'name' => 'Production',
|
||||
'type' => 'production',
|
||||
]);
|
||||
|
||||
AdvanceSubmission::create([
|
||||
'advance_section_id' => $section->id,
|
||||
'submitted_by_name' => 'TM',
|
||||
'submitted_by_email' => 'tm@example.test',
|
||||
'submitted_at' => now(),
|
||||
'status' => 'pending',
|
||||
'data' => [],
|
||||
]);
|
||||
|
||||
$eng->delete();
|
||||
|
||||
// Performance is soft-deleted (trashed, not removed).
|
||||
$this->assertNotNull(Performance::withoutGlobalScope(OrganisationScope::class)->withTrashed()->find($perf->id));
|
||||
$this->assertSoftDeleted($perf);
|
||||
|
||||
// AdvanceSection is hard-deleted.
|
||||
$this->assertNull(AdvanceSection::withoutGlobalScope(OrganisationScope::class)->find($section->id));
|
||||
|
||||
// Note: `advance_submissions.advance_section_id` currently uses
|
||||
// `cascadeOnDelete()`, so submissions are removed with their section.
|
||||
// RFC v0.2 §5.4 calls submissions "audit-immutable" — interpreted
|
||||
// here as "no application code mutates them post-creation". A
|
||||
// future migration may switch the FK to nullOnDelete to preserve
|
||||
// rows past section hard-delete; out of Session 1 scope.
|
||||
}
|
||||
}
|
||||
60
api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php
Normal file
60
api/tests/Feature/Artist/ArtistTimetableDevSeederTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistContact;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Genre;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Models\Stage;
|
||||
use App\Models\StageDay;
|
||||
use Database\Seeders\ArtistTimetableDevSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ArtistTimetableDevSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_seeder_produces_expected_fixture_counts(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
/** @var Event $festival */
|
||||
$festival = Event::factory()->for($org)->festival()->create([
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-12',
|
||||
]);
|
||||
$vrijdag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-10',
|
||||
'end_date' => '2026-07-10',
|
||||
]);
|
||||
$zaterdag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-11',
|
||||
'end_date' => '2026-07-11',
|
||||
]);
|
||||
$zondag = Event::factory()->for($org)->subEvent($festival)->create([
|
||||
'start_date' => '2026-07-12',
|
||||
'end_date' => '2026-07-12',
|
||||
]);
|
||||
|
||||
ArtistTimetableDevSeeder::seedForFestival($org, $festival, [$vrijdag, $zaterdag, $zondag]);
|
||||
|
||||
$this->assertSame(4, Genre::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(4, Stage::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(12, StageDay::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(6, Artist::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(6, ArtistContact::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(12, ArtistEngagement::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(13, Performance::withoutGlobalScope(OrganisationScope::class)->count());
|
||||
$this->assertSame(
|
||||
1,
|
||||
Performance::withoutGlobalScope(OrganisationScope::class)->whereNull('stage_id')->count()
|
||||
);
|
||||
}
|
||||
}
|
||||
69
api/tests/Feature/Artist/PerformanceObserverTest.php
Normal file
69
api/tests/Feature/Artist/PerformanceObserverTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Artist;
|
||||
|
||||
use App\Enums\Artist\ArtistEngagementStatus;
|
||||
use App\Models\Artist;
|
||||
use App\Models\ArtistEngagement;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Performance;
|
||||
use App\Models\Stage;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PerformanceObserverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makePerformance(): Performance
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$event = Event::factory()->for($org)->create();
|
||||
$artist = Artist::factory()->create(['organisation_id' => $org->id]);
|
||||
$eng = ArtistEngagement::create([
|
||||
'artist_id' => $artist->id,
|
||||
'event_id' => $event->id,
|
||||
'booking_status' => ArtistEngagementStatus::Confirmed->value,
|
||||
]);
|
||||
$stage = Stage::factory()->for($event)->create();
|
||||
$start = CarbonImmutable::now();
|
||||
|
||||
return Performance::create([
|
||||
'engagement_id' => $eng->id,
|
||||
'event_id' => $event->id,
|
||||
'stage_id' => $stage->id,
|
||||
'start_at' => $start,
|
||||
'end_at' => $start->addHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_version_starts_at_zero_on_create(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
$this->assertSame(0, $perf->fresh()->version);
|
||||
}
|
||||
|
||||
public function test_version_increments_by_one_per_update(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
|
||||
$perf->update(['notes' => 'first edit']);
|
||||
$this->assertSame(1, $perf->fresh()->version);
|
||||
|
||||
$perf->update(['notes' => 'second edit']);
|
||||
$this->assertSame(2, $perf->fresh()->version);
|
||||
}
|
||||
|
||||
public function test_version_does_not_increment_on_no_op_save(): void
|
||||
{
|
||||
$perf = $this->makePerformance();
|
||||
|
||||
// Saving without dirty attributes should not bump version.
|
||||
$perf->save();
|
||||
$this->assertSame(0, $perf->fresh()->version);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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']]],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
169
api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php
Normal file
169
api/tests/Unit/Models/Artist/ArtistDomainModelsTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user