diff --git a/backend/app/Http/Requests/StoreUserAffiliationRequest.php b/backend/app/Http/Requests/StoreUserAffiliationRequest.php index 4bf4c90..c34d133 100644 --- a/backend/app/Http/Requests/StoreUserAffiliationRequest.php +++ b/backend/app/Http/Requests/StoreUserAffiliationRequest.php @@ -2,12 +2,14 @@ namespace App\Http\Requests; +use App\Http\Traits\ValidatesUserAffiliation; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StoreUserAffiliationRequest extends FormRequest { + use ValidatesUserAffiliation; + /** * L'autorizzazione (solo Admin) è gestita dal middleware di rotta. */ @@ -21,24 +23,6 @@ class StoreUserAffiliationRequest extends FormRequest */ public function rules(): array { - $rules = [ - 'institution_id' => ['required', 'integer', 'exists:institutions,id'], - 'user_id' => ['required', 'integer', 'exists:users,id'], - 'user_position_id' => ['required', 'integer', 'exists:user_positions,id'], - 'start_year' => ['required', 'integer', 'min:1800', 'max:'.now()->year], - 'end_year' => ['nullable', 'integer', 'min:1800', 'gte:start_year'], - ]; - - // Un utente non può avere più di un incarico aperto (end_year nullo) nello - // stesso ente: il guard definitivo è l'indice unique sulla colonna virtuale - // is_open, qui serve solo a restituire un 422 leggibile invece di un 500 SQL. - if (! $this->filled('end_year')) { - $rules['institution_id'][] = Rule::unique('user_affiliations', 'institution_id') - ->where('user_id', $this->input('user_id')) - ->whereNull('end_year') - ->whereNull('deleted_at'); - } - - return $rules; + return $this->userAffiliationRules(); } } diff --git a/backend/app/Http/Requests/UpdateUserAffiliationRequest.php b/backend/app/Http/Requests/UpdateUserAffiliationRequest.php index 3dfb2af..01d8fb5 100644 --- a/backend/app/Http/Requests/UpdateUserAffiliationRequest.php +++ b/backend/app/Http/Requests/UpdateUserAffiliationRequest.php @@ -2,12 +2,14 @@ namespace App\Http\Requests; +use App\Http\Traits\ValidatesUserAffiliation; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class UpdateUserAffiliationRequest extends FormRequest { + use ValidatesUserAffiliation; + /** * L'autorizzazione (solo Admin) è gestita dal middleware di rotta. */ @@ -21,26 +23,6 @@ class UpdateUserAffiliationRequest extends FormRequest */ public function rules(): array { - $id = $this->route('user_affiliation')->id; - - $rules = [ - 'institution_id' => ['required', 'integer', 'exists:institutions,id'], - 'user_id' => ['required', 'integer', 'exists:users,id'], - 'user_position_id' => ['required', 'integer', 'exists:user_positions,id'], - 'start_year' => ['required', 'integer', 'min:1800', 'max:'.now()->year], - 'end_year' => ['nullable', 'integer', 'min:1800', 'gte:start_year'], - ]; - - // Stesso vincolo di StoreUserAffiliationRequest: niente doppio incarico - // aperto sullo stesso ente, escludendo il record che si sta modificando. - if (! $this->filled('end_year')) { - $rules['institution_id'][] = Rule::unique('user_affiliations', 'institution_id') - ->where('user_id', $this->input('user_id')) - ->whereNull('end_year') - ->whereNull('deleted_at') - ->ignore($id); - } - - return $rules; + return $this->userAffiliationRules($this->route('user_affiliation')->id); } } diff --git a/backend/app/Http/Traits/ValidatesUserAffiliation.php b/backend/app/Http/Traits/ValidatesUserAffiliation.php new file mode 100644 index 0000000..d8e24fd --- /dev/null +++ b/backend/app/Http/Traits/ValidatesUserAffiliation.php @@ -0,0 +1,40 @@ +> + */ + protected function userAffiliationRules(?int $ignoreId = null): array + { + $rules = [ + 'institution_id' => ['required', 'integer', 'exists:institutions,id'], + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'user_position_id' => ['required', 'integer', 'exists:user_positions,id'], + 'start_year' => ['required', 'integer', 'min:1800', 'max:'.now()->year], + 'end_year' => ['nullable', 'integer', 'min:1800', 'gte:start_year'], + ]; + + // Un utente non può avere più di un incarico aperto (end_year nullo) nello + // stesso ente: il guard definitivo è l'indice unique sulla colonna virtuale + // is_open, qui serve solo a restituire un 422 leggibile invece di un 500 SQL. + // I soft-deleted non contano: un cestinato non blocca un nuovo incarico aperto. + if (! $this->filled('end_year')) { + $rules['institution_id'][] = Rule::unique('user_affiliations', 'institution_id') + ->where('user_id', $this->input('user_id')) + ->whereNull('end_year') + ->whereNull('deleted_at') + ->ignore($ignoreId); + } + + return $rules; + } +} diff --git a/backend/tests/Feature/Models/InstitutionTest.php b/backend/tests/Feature/Models/InstitutionTest.php index 1c0fdd9..3c5be45 100644 --- a/backend/tests/Feature/Models/InstitutionTest.php +++ b/backend/tests/Feature/Models/InstitutionTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Models; use App\Models\Institution; use App\Models\InstitutionLink; use App\Models\Lists\InstitutionCategory; +use App\Models\UserAffiliation; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Tests\TestCase; @@ -86,6 +87,14 @@ class InstitutionTest extends TestCase $this->assertCount(1, $institution->refresh()->links); } + public function test_it_has_many_affiliations(): void + { + $institution = Institution::factory()->create(); + UserAffiliation::factory()->create(['institution_id' => $institution->id]); + + $this->assertCount(1, $institution->refresh()->affiliations); + } + public function test_it_is_soft_deleted(): void { $institution = Institution::factory()->create(); diff --git a/backend/tests/Feature/Models/UserTest.php b/backend/tests/Feature/Models/UserTest.php new file mode 100644 index 0000000..d925f8c --- /dev/null +++ b/backend/tests/Feature/Models/UserTest.php @@ -0,0 +1,21 @@ +create(); + UserAffiliation::factory()->create(['user_id' => $user->id]); + + $this->assertCount(1, $user->refresh()->affiliations); + } +} diff --git a/backend/tests/Feature/UserAffiliationControllerTest.php b/backend/tests/Feature/UserAffiliationControllerTest.php index f244f2b..1ad702f 100644 --- a/backend/tests/Feature/UserAffiliationControllerTest.php +++ b/backend/tests/Feature/UserAffiliationControllerTest.php @@ -339,6 +339,28 @@ class UserAffiliationControllerTest extends TestCase $this->assertNotSoftDeleted($trashed); } + public function test_restore_succeeds_for_a_closed_affiliation_even_when_another_one_is_open(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $institution = Institution::factory()->create(); + $user = User::factory()->create(); + $trashed = UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => 2019, + ]); + $trashed->delete(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]); + + $this->postJson(self::BASE_ROUTE."/{$trashed->id}/restore")->assertOk(); + + $this->assertNotSoftDeleted($trashed); + } + public function test_operational_user_can_force_delete_an_affiliation(): void { $this->actingAs($this->operationalUser(), 'sanctum');