From 6a07c3e9227026d5ba494d98a52d495439a0b9f3 Mon Sep 17 00:00:00 2001 From: Giuseppe Naponiello Date: Wed, 24 Jun 2026 16:30:21 +0200 Subject: [PATCH] user affiliation schema completed --- .../app/Etl/Importers/InstitutionImporter.php | 6 +- .../Controllers/UserAffiliationController.php | 138 +++++++ .../Requests/IndexUserAffiliationRequest.php | 33 ++ .../Requests/StoreUserAffiliationRequest.php | 44 +++ .../Requests/UpdateUserAffiliationRequest.php | 46 +++ backend/app/Models/Institution.php | 10 + backend/app/Models/User.php | 13 +- backend/app/Models/UserAffiliation.php | 97 +++++ .../factories/UserAffiliationFactory.php | 33 ++ ...4_100625_create_user_affiliation_table.php | 53 +++ ...24_110000_add_legacy_id_to_users_table.php | 30 ++ backend/routes/api/user-affiliations.php | 21 + .../Feature/UserAffiliationControllerTest.php | 369 ++++++++++++++++++ 13 files changed, 890 insertions(+), 3 deletions(-) create mode 100644 backend/app/Http/Controllers/UserAffiliationController.php create mode 100644 backend/app/Http/Requests/IndexUserAffiliationRequest.php create mode 100644 backend/app/Http/Requests/StoreUserAffiliationRequest.php create mode 100644 backend/app/Http/Requests/UpdateUserAffiliationRequest.php create mode 100644 backend/app/Models/UserAffiliation.php create mode 100644 backend/database/factories/UserAffiliationFactory.php create mode 100644 backend/database/migrations/2026_06_24_100625_create_user_affiliation_table.php create mode 100644 backend/database/migrations/2026_06_24_110000_add_legacy_id_to_users_table.php create mode 100644 backend/routes/api/user-affiliations.php create mode 100644 backend/tests/Feature/UserAffiliationControllerTest.php diff --git a/backend/app/Etl/Importers/InstitutionImporter.php b/backend/app/Etl/Importers/InstitutionImporter.php index c7b8275..10bb543 100644 --- a/backend/app/Etl/Importers/InstitutionImporter.php +++ b/backend/app/Etl/Importers/InstitutionImporter.php @@ -77,8 +77,10 @@ class InstitutionImporter implements Importer if (! in_array($categoryId, $validCategoryIds, true)) { $summary->warn(sprintf( - "Institution legacy #%d ('%s'): category %d assente nel lookup v2 → saltata.", - $row->id, trim((string) $row->name), $categoryId, + "Institution legacy #%d ('%s') : category %d assente nel lookup v2 → saltata.", + $row->id, + trim((string) $row->name), + $categoryId, )); $summary->skipped++; diff --git a/backend/app/Http/Controllers/UserAffiliationController.php b/backend/app/Http/Controllers/UserAffiliationController.php new file mode 100644 index 0000000..f82b714 --- /dev/null +++ b/backend/app/Http/Controllers/UserAffiliationController.php @@ -0,0 +1,138 @@ +with(['institution', 'user', 'userPosition']) + ->orderByDesc('start_year'); + + $this->applyTrashed($query, $request->input('trashed')); + + if ($request->filled('institution_id')) { + $query->where('institution_id', $request->integer('institution_id')); + } + + if ($request->filled('user_id')) { + $query->where('user_id', $request->integer('user_id')); + } + + if ($request->filled('user_position_id')) { + $query->where('user_position_id', $request->integer('user_position_id')); + } + + if ($request->filled('open')) { + $request->boolean('open') + ? $query->whereNotNull('is_open') + : $query->whereNull('is_open'); + } + + $affiliations = $query->paginate($request->integer('per_page') ?: 20); + + return $this->paginatedCollectionResponse($affiliations); + } + + /** + * Crea un'affiliazione. + */ + public function store(StoreUserAffiliationRequest $request): JsonResponse + { + $affiliation = UserAffiliation::create($request->validated()); + + return $this->createdResponse($affiliation->load(['institution', 'user', 'userPosition'])); + } + + /** + * Dettaglio (con ente, utente e posizione). + */ + public function show(UserAffiliation $userAffiliation): JsonResponse + { + return $this->okResponse($userAffiliation->load(['institution', 'user', 'userPosition'])); + } + + /** + * Aggiorna un'affiliazione. + */ + public function update(UpdateUserAffiliationRequest $request, UserAffiliation $userAffiliation): JsonResponse + { + $userAffiliation->update($request->validated()); + + return $this->updatedResponse($userAffiliation->load(['institution', 'user', 'userPosition'])); + } + + /** + * Cestina (soft delete). + */ + public function destroy(UserAffiliation $userAffiliation): JsonResponse + { + $userAffiliation->delete(); + + return $this->deletedResponse(); + } + + /** + * Ripristina un'affiliazione cestinata. Bloccato (409) se nel frattempo è + * stata aperta un'altra affiliazione per lo stesso ente+utente: va prima + * chiusa/eliminata quella attuale, oppure si elimina definitivamente questa. + */ + public function restore(UserAffiliation $userAffiliation): JsonResponse + { + if ($userAffiliation->hasConflictingOpenAffiliation()) { + return $this->conflictResponse( + 'Cannot restore: another open affiliation already exists for this user at this institution.' + ); + } + + $userAffiliation->restore(); + + return $this->restoredResponse($userAffiliation->load(['institution', 'user', 'userPosition'])); + } + + /** + * Elimina definitivamente un'affiliazione. + */ + public function forceDestroy(UserAffiliation $userAffiliation): JsonResponse + { + $userAffiliation->forceDelete(); + + return $this->deletedResponse(); + } + + /** + * Include i soft-deleted: `with` (tutti) o `only` (solo cestinati). + * + * @param Builder $query + */ + private function applyTrashed(Builder $query, ?string $trashed): void + { + match ($trashed) { + 'with' => $query->withTrashed(), + 'only' => $query->onlyTrashed(), + default => null, + }; + } +} diff --git a/backend/app/Http/Requests/IndexUserAffiliationRequest.php b/backend/app/Http/Requests/IndexUserAffiliationRequest.php new file mode 100644 index 0000000..0d047d3 --- /dev/null +++ b/backend/app/Http/Requests/IndexUserAffiliationRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'institution_id' => ['nullable', 'integer', 'exists:institutions,id'], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + 'user_position_id' => ['nullable', 'integer', 'exists:user_positions,id'], + 'open' => ['nullable', 'boolean'], + 'trashed' => ['nullable', Rule::in(['with', 'only'])], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreUserAffiliationRequest.php b/backend/app/Http/Requests/StoreUserAffiliationRequest.php new file mode 100644 index 0000000..4bf4c90 --- /dev/null +++ b/backend/app/Http/Requests/StoreUserAffiliationRequest.php @@ -0,0 +1,44 @@ +|string> + */ + 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; + } +} diff --git a/backend/app/Http/Requests/UpdateUserAffiliationRequest.php b/backend/app/Http/Requests/UpdateUserAffiliationRequest.php new file mode 100644 index 0000000..3dfb2af --- /dev/null +++ b/backend/app/Http/Requests/UpdateUserAffiliationRequest.php @@ -0,0 +1,46 @@ +|string> + */ + 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; + } +} diff --git a/backend/app/Models/Institution.php b/backend/app/Models/Institution.php index 6cf5768..c405ce9 100644 --- a/backend/app/Models/Institution.php +++ b/backend/app/Models/Institution.php @@ -87,4 +87,14 @@ class Institution extends Model implements Auditable { return $this->hasMany(InstitutionLink::class); } + + /** + * Storico delle affiliazioni (staff) di questa istituzione. + * + * @return HasMany + */ + public function affiliations(): HasMany + { + return $this->hasMany(UserAffiliation::class); + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 47024e4..da85b91 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -7,6 +7,7 @@ use App\Notifications\ResetPasswordNotification; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -16,7 +17,7 @@ use OwenIt\Auditing\Contracts\Auditable; class User extends Authenticatable implements Auditable, MustVerifyEmail { - /** @use HasFactory */ + /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable, SoftDeletes; use \OwenIt\Auditing\Auditable; @@ -85,6 +86,16 @@ class User extends Authenticatable implements Auditable, MustVerifyEmail return $this->belongsTo(UserRole::class, 'role_id'); } + /** + * Storico delle affiliazioni (incarichi) di questo utente. + * + * @return HasMany + */ + public function affiliations(): HasMany + { + return $this->hasMany(UserAffiliation::class); + } + /** * Send the password reset notification. */ diff --git a/backend/app/Models/UserAffiliation.php b/backend/app/Models/UserAffiliation.php new file mode 100644 index 0000000..0d4ade9 --- /dev/null +++ b/backend/app/Models/UserAffiliation.php @@ -0,0 +1,97 @@ + + */ + protected $hidden = [ + 'legacy_user_id', + 'legacy_institution_id', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'institution_id' => 'integer', + 'user_id' => 'integer', + 'user_position_id' => 'integer', + 'start_year' => 'integer', + 'end_year' => 'integer', + 'is_open' => 'boolean', + 'legacy_user_id' => 'integer', + 'legacy_institution_id' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function institution(): BelongsTo + { + return $this->belongsTo(Institution::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function userPosition(): BelongsTo + { + return $this->belongsTo(UserPosition::class); + } + + /** + * Esiste già un'altra affiliazione aperta per lo stesso ente+utente: un + * restore qui urterebbe l'indice unique su (institution_id, user_id, is_open). + */ + public function hasConflictingOpenAffiliation(): bool + { + if (filled($this->end_year)) { + return false; + } + + return self::query() + ->where('institution_id', $this->institution_id) + ->where('user_id', $this->user_id) + ->where('id', '!=', $this->id) + ->whereNotNull('is_open') + ->exists(); + } +} diff --git a/backend/database/factories/UserAffiliationFactory.php b/backend/database/factories/UserAffiliationFactory.php new file mode 100644 index 0000000..0d6974b --- /dev/null +++ b/backend/database/factories/UserAffiliationFactory.php @@ -0,0 +1,33 @@ + + */ +class UserAffiliationFactory extends Factory +{ + protected $model = UserAffiliation::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'institution_id' => Institution::factory(), + 'user_id' => User::factory(), + 'user_position_id' => UserPosition::factory(), + 'start_year' => fake()->numberBetween(1980, (int) date('Y')), + 'end_year' => null, + 'legacy_user_id' => null, + 'legacy_institution_id' => null, + ]; + } +} diff --git a/backend/database/migrations/2026_06_24_100625_create_user_affiliation_table.php b/backend/database/migrations/2026_06_24_100625_create_user_affiliation_table.php new file mode 100644 index 0000000..2ea99c4 --- /dev/null +++ b/backend/database/migrations/2026_06_24_100625_create_user_affiliation_table.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('institution_id') + ->constrained('institutions') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->foreignId('user_position_id') + ->constrained('user_positions') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + $table->integer('start_year'); + $table->integer('end_year')->nullable(); + $table->softDeletes(); + // Aperta = end_year nullo E non cestinata: un soft delete libera lo slot + // per un nuovo incarico aperto, senza dover aspettare un restore/force. + $table->boolean('is_open')->virtualAs('IF(`end_year` IS NULL AND `deleted_at` IS NULL, 1, NULL)'); + // Legacy: in v1 affiliazione/posizione vivevano dentro "person" (un solo + // incarico per persona), qui normalizzate per permettere più affiliazioni. + // legacy_user_id identifica univocamente la riga "person" d'origine (chiave + // di upsert per l'ETL); legacy_institution_id è solo un riferimento + // informativo, condiviso da più affiliazioni verso lo stesso ente. + $table->unsignedBigInteger('legacy_user_id')->nullable()->unique(); + $table->unsignedBigInteger('legacy_institution_id')->nullable(); + $table->timestamps(); + $table->unique(['institution_id', 'user_id', 'is_open']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_affiliations'); + } +}; diff --git a/backend/database/migrations/2026_06_24_110000_add_legacy_id_to_users_table.php b/backend/database/migrations/2026_06_24_110000_add_legacy_id_to_users_table.php new file mode 100644 index 0000000..ff69a46 --- /dev/null +++ b/backend/database/migrations/2026_06_24_110000_add_legacy_id_to_users_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('legacy_id')->nullable()->unique()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('legacy_id'); + }); + } +}; diff --git a/backend/routes/api/user-affiliations.php b/backend/routes/api/user-affiliations.php new file mode 100644 index 0000000..bea25de --- /dev/null +++ b/backend/routes/api/user-affiliations.php @@ -0,0 +1,21 @@ +group(function () { + Route::apiResource('user-affiliations', UserAffiliationController::class)->only(['index', 'show']); +}); + +// Scrittura + soft delete: utente pienamente operativo (non riservata all'Admin, +// a differenza di institutions/institution-categories). +Route::middleware('tier.app')->group(function () { + Route::apiResource('user-affiliations', UserAffiliationController::class)->only(['store', 'update', 'destroy']); + + // Soft delete: ripristino e cancellazione definitiva (binding sui cestinati). + Route::post('user-affiliations/{user_affiliation}/restore', [UserAffiliationController::class, 'restore'])->withTrashed(); + Route::delete('user-affiliations/{user_affiliation}/force', [UserAffiliationController::class, 'forceDestroy'])->withTrashed(); +}); diff --git a/backend/tests/Feature/UserAffiliationControllerTest.php b/backend/tests/Feature/UserAffiliationControllerTest.php new file mode 100644 index 0000000..f244f2b --- /dev/null +++ b/backend/tests/Feature/UserAffiliationControllerTest.php @@ -0,0 +1,369 @@ + 'application/json']; + + /** + * @param array $overrides + * @return array + */ + private function validPayload(array $overrides = []): array + { + return array_merge([ + 'institution_id' => Institution::factory()->create()->id, + 'user_id' => User::factory()->create()->id, + 'user_position_id' => UserPosition::factory()->create()->id, + 'start_year' => 2020, + 'end_year' => null, + ], $overrides); + } + + // --- Lettura (pubblica, throttle:public) ---------------------------------- + + public function test_index_lists_affiliations_for_an_operational_user(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + UserAffiliation::factory()->count(2)->create(); + + $this->getJson(self::BASE_ROUTE) + ->assertOk() + ->assertJsonStructure(['message', 'data', 'meta' => ['current_page', 'total']]) + ->assertJsonCount(2, 'data'); + } + + public function test_index_filters_by_institution_user_position_and_open(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $institution = Institution::factory()->create(); + $user = User::factory()->create(); + $position = UserPosition::factory()->create(); + + $open = UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'user_position_id' => $position->id, + 'end_year' => null, + ]); + UserAffiliation::factory()->create(['end_year' => 2019]); + + $this->getJson(self::BASE_ROUTE."?institution_id={$institution->id}") + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->getJson(self::BASE_ROUTE."?user_id={$user->id}") + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->getJson(self::BASE_ROUTE."?user_position_id={$position->id}") + ->assertOk() + ->assertJsonCount(1, 'data'); + + $this->getJson(self::BASE_ROUTE.'?open=1') + ->assertOk() + ->assertJsonPath('data.0.id', $open->id) + ->assertJsonCount(1, 'data'); + + $this->getJson(self::BASE_ROUTE.'?open=0') + ->assertOk() + ->assertJsonCount(1, 'data'); + } + + public function test_index_can_list_only_trashed(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $trashed = UserAffiliation::factory()->create(); + UserAffiliation::factory()->create(); + $trashed->delete(); + + $this->getJson(self::BASE_ROUTE)->assertJsonCount(1, 'data'); + $this->getJson(self::BASE_ROUTE.'?trashed=only') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $trashed->id); + $this->getJson(self::BASE_ROUTE.'?trashed=with')->assertJsonCount(2, 'data'); + } + + public function test_show_returns_affiliation_with_institution_user_and_position(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create(); + + $this->getJson(self::BASE_ROUTE."/{$affiliation->id}") + ->assertOk() + ->assertJsonPath('data.id', $affiliation->id) + ->assertJsonStructure(['data' => ['institution', 'user', 'user_position']]); + } + + public function test_legacy_fields_are_hidden_from_the_json_output(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create([ + 'legacy_user_id' => 123, + 'legacy_institution_id' => 456, + ]); + + $this->getJson(self::BASE_ROUTE."/{$affiliation->id}") + ->assertOk() + ->assertJsonMissingPath('data.legacy_user_id') + ->assertJsonMissingPath('data.legacy_institution_id'); + } + + // --- Scrittura (tier.app, non riservata all'Admin) ------------------------ + + public function test_operational_user_can_create_an_affiliation(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + + $this->postJson(self::BASE_ROUTE, $this->validPayload(), self::JSON_HEADERS) + ->assertCreated() + ->assertJsonStructure(['data' => ['id', 'institution', 'user', 'user_position']]); + + $this->assertDatabaseCount('user_affiliations', 1); + } + + public function test_create_validates_required_fields(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + + $this->postJson(self::BASE_ROUTE, []) + ->assertStatus(422) + ->assertJsonValidationErrors(['institution_id', 'user_id', 'user_position_id', 'start_year']); + } + + public function test_create_rejects_a_second_open_affiliation_for_the_same_institution_and_user(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $institution = Institution::factory()->create(); + $user = User::factory()->create(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]); + + $this->postJson(self::BASE_ROUTE, $this->validPayload([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ])) + ->assertStatus(422) + ->assertJsonValidationErrors(['institution_id']); + } + + public function test_create_allows_a_new_open_affiliation_after_the_previous_one_was_closed(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $institution = Institution::factory()->create(); + $user = User::factory()->create(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => 2019, + ]); + + $this->postJson(self::BASE_ROUTE, $this->validPayload([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]))->assertCreated(); + } + + public function test_create_allows_a_new_open_affiliation_after_the_previous_one_was_soft_deleted(): 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' => null, + ]); + $trashed->delete(); + + $this->postJson(self::BASE_ROUTE, $this->validPayload([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]))->assertCreated(); + } + + public function test_create_allows_two_open_affiliations_for_the_same_user_at_different_institutions(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $user = User::factory()->create(); + UserAffiliation::factory()->create(['user_id' => $user->id, 'end_year' => null]); + + $this->postJson(self::BASE_ROUTE, $this->validPayload(['user_id' => $user->id, 'end_year' => null])) + ->assertCreated(); + } + + public function test_operational_user_can_update_an_affiliation(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create(['start_year' => 2018, 'end_year' => null]); + + $payload = $this->validPayload([ + 'institution_id' => $affiliation->institution_id, + 'user_id' => $affiliation->user_id, + 'end_year' => 2022, + ]); + + $this->putJson(self::BASE_ROUTE."/{$affiliation->id}", $payload) + ->assertOk() + ->assertJsonPath('data.end_year', 2022); + + $this->assertSame(2022, $affiliation->fresh()->end_year); + } + + public function test_update_allows_keeping_the_affiliation_open(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create(['end_year' => null]); + + $payload = $this->validPayload([ + 'institution_id' => $affiliation->institution_id, + 'user_id' => $affiliation->user_id, + 'end_year' => null, + ]); + + $this->putJson(self::BASE_ROUTE."/{$affiliation->id}", $payload)->assertOk(); + } + + public function test_update_rejects_opening_a_second_affiliation_for_the_same_institution_and_user(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $institution = Institution::factory()->create(); + $user = User::factory()->create(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]); + $closed = UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => 2019, + ]); + + $payload = $this->validPayload([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]); + + $this->putJson(self::BASE_ROUTE."/{$closed->id}", $payload) + ->assertStatus(422) + ->assertJsonValidationErrors(['institution_id']); + } + + public function test_operational_user_can_soft_delete_an_affiliation(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create(); + + $this->deleteJson(self::BASE_ROUTE."/{$affiliation->id}")->assertOk(); + + $this->assertSoftDeleted($affiliation); + } + + public function test_operational_user_can_restore_a_trashed_affiliation(): void + { + $this->actingAs($this->operationalUser(), 'sanctum'); + $affiliation = UserAffiliation::factory()->create(); + $affiliation->delete(); + + $this->postJson(self::BASE_ROUTE."/{$affiliation->id}/restore") + ->assertOk() + ->assertJsonPath('data.id', $affiliation->id); + + $this->assertNotSoftDeleted($affiliation); + } + + public function test_restore_is_blocked_when_another_open_affiliation_exists_for_the_same_institution_and_user(): 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' => null, + ]); + $trashed->delete(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => null, + ]); + + $this->postJson(self::BASE_ROUTE."/{$trashed->id}/restore") + ->assertStatus(409) + ->assertJsonPath('in_use', true); + + $this->assertSoftDeleted($trashed); + } + + public function test_restore_succeeds_when_the_conflicting_affiliation_was_closed(): 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' => null, + ]); + $trashed->delete(); + UserAffiliation::factory()->create([ + 'institution_id' => $institution->id, + 'user_id' => $user->id, + 'end_year' => 2019, + ]); + + $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'); + $affiliation = UserAffiliation::factory()->create(); + $affiliation->delete(); + + $this->deleteJson(self::BASE_ROUTE."/{$affiliation->id}/force")->assertOk(); + + $this->assertDatabaseMissing('user_affiliations', ['id' => $affiliation->id]); + } + + // --- Autorizzazione --------------------------------------------------------- + + public function test_guests_can_read_affiliations(): void + { + UserAffiliation::factory()->create(); + + $this->getJson(self::BASE_ROUTE)->assertOk(); + } + + public function test_guests_cannot_write_affiliations(): void + { + $affiliation = UserAffiliation::factory()->create(); + + $this->postJson(self::BASE_ROUTE, [])->assertUnauthorized(); + $this->deleteJson(self::BASE_ROUTE."/{$affiliation->id}")->assertUnauthorized(); + } +}