Compare commits
2 Commits
032f6a08df
...
96b0dc76aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b0dc76aa | ||
|
|
6a07c3e922 |
@@ -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++;
|
||||
|
||||
|
||||
138
backend/app/Http/Controllers/UserAffiliationController.php
Normal file
138
backend/app/Http/Controllers/UserAffiliationController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\IndexUserAffiliationRequest;
|
||||
use App\Http\Requests\StoreUserAffiliationRequest;
|
||||
use App\Http\Requests\UpdateUserAffiliationRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\UserAffiliation;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* CRUD delle affiliazioni utente-ente (scrittura riservata agli Admin via tier
|
||||
* di rotta). Risorsa standalone: institution_id, user_id e user_position_id
|
||||
* viaggiano nel body, non nella route (l'affiliazione ha due "genitori" forti).
|
||||
*
|
||||
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
|
||||
* definitivamente.
|
||||
*/
|
||||
class UserAffiliationController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco paginato, con filtri opzionali (ente, utente, posizione, aperte/chiuse, cestino).
|
||||
*/
|
||||
public function index(IndexUserAffiliationRequest $request): JsonResponse
|
||||
{
|
||||
$query = UserAffiliation::query()
|
||||
->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<UserAffiliation> $query
|
||||
*/
|
||||
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||
{
|
||||
match ($trashed) {
|
||||
'with' => $query->withTrashed(),
|
||||
'only' => $query->onlyTrashed(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
33
backend/app/Http/Requests/IndexUserAffiliationRequest.php
Normal file
33
backend/app/Http/Requests/IndexUserAffiliationRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexUserAffiliationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione è gestita dal middleware di rotta (tier.app).
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
backend/app/Http/Requests/StoreUserAffiliationRequest.php
Normal file
28
backend/app/Http/Requests/StoreUserAffiliationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Traits\ValidatesUserAffiliation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreUserAffiliationRequest extends FormRequest
|
||||
{
|
||||
use ValidatesUserAffiliation;
|
||||
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->userAffiliationRules();
|
||||
}
|
||||
}
|
||||
28
backend/app/Http/Requests/UpdateUserAffiliationRequest.php
Normal file
28
backend/app/Http/Requests/UpdateUserAffiliationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Traits\ValidatesUserAffiliation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateUserAffiliationRequest extends FormRequest
|
||||
{
|
||||
use ValidatesUserAffiliation;
|
||||
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->userAffiliationRules($this->route('user_affiliation')->id);
|
||||
}
|
||||
}
|
||||
40
backend/app/Http/Traits/ValidatesUserAffiliation.php
Normal file
40
backend/app/Http/Traits/ValidatesUserAffiliation.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Traits;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Regole condivise da Store/UpdateUserAffiliationRequest. Unica differenza tra
|
||||
* le due: l'update ignora se stesso nel controllo di unicità sull'incarico aperto.
|
||||
*/
|
||||
trait ValidatesUserAffiliation
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -87,4 +87,14 @@ class Institution extends Model implements Auditable
|
||||
{
|
||||
return $this->hasMany(InstitutionLink::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storico delle affiliazioni (staff) di questa istituzione.
|
||||
*
|
||||
* @return HasMany<UserAffiliation, $this>
|
||||
*/
|
||||
public function affiliations(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserAffiliation::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserFactory> */
|
||||
/** @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<UserAffiliation, $this>
|
||||
*/
|
||||
public function affiliations(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserAffiliation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*/
|
||||
|
||||
97
backend/app/Models/UserAffiliation.php
Normal file
97
backend/app/Models/UserAffiliation.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Lists\UserPosition;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class UserAffiliation extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'institution_id',
|
||||
'user_id',
|
||||
'user_position_id',
|
||||
'start_year',
|
||||
'end_year',
|
||||
'legacy_user_id',
|
||||
'legacy_institution_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bookkeeping ETL, non rilevanti per l'admin UI.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'legacy_user_id',
|
||||
'legacy_institution_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<Institution, $this>
|
||||
*/
|
||||
public function institution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Institution::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<UserPosition, $this>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
33
backend/database/factories/UserAffiliationFactory.php
Normal file
33
backend/database/factories/UserAffiliationFactory.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\UserPosition;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAffiliation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserAffiliation>
|
||||
*/
|
||||
class UserAffiliationFactory extends Factory
|
||||
{
|
||||
protected $model = UserAffiliation::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_affiliations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
|
||||
// risolvere le FK delle tabelle dipendenti (come institutions.legacy_id).
|
||||
$table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('legacy_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
21
backend/routes/api/user-affiliations.php
Normal file
21
backend/routes/api/user-affiliations.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserAffiliationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e stack `api` applicati dal loader in bootstrap/app.php.
|
||||
|
||||
// Lettura: pubblica con throttling.
|
||||
Route::middleware('throttle:public')->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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
21
backend/tests/Feature/Models/UserTest.php
Normal file
21
backend/tests/Feature/Models/UserTest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserAffiliation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_has_many_affiliations(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
UserAffiliation::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$this->assertCount(1, $user->refresh()->affiliations);
|
||||
}
|
||||
}
|
||||
391
backend/tests/Feature/UserAffiliationControllerTest.php
Normal file
391
backend/tests/Feature/UserAffiliationControllerTest.php
Normal file
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\UserPosition;
|
||||
use App\Models\User;
|
||||
use App\Models\UserAffiliation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserAffiliationControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE_ROUTE = '/api/user-affiliations';
|
||||
|
||||
private const JSON_HEADERS = ['Accept' => 'application/json'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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_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');
|
||||
$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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user