Compare commits
2 Commits
032f6a08df
...
96b0dc76aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b0dc76aa | ||
|
|
6a07c3e922 |
@@ -78,7 +78,9 @@ class InstitutionImporter implements Importer
|
|||||||
if (! in_array($categoryId, $validCategoryIds, true)) {
|
if (! in_array($categoryId, $validCategoryIds, true)) {
|
||||||
$summary->warn(sprintf(
|
$summary->warn(sprintf(
|
||||||
"Institution legacy #%d ('%s') : category %d assente nel lookup v2 → saltata.",
|
"Institution legacy #%d ('%s') : category %d assente nel lookup v2 → saltata.",
|
||||||
$row->id, trim((string) $row->name), $categoryId,
|
$row->id,
|
||||||
|
trim((string) $row->name),
|
||||||
|
$categoryId,
|
||||||
));
|
));
|
||||||
$summary->skipped++;
|
$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);
|
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\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -16,7 +17,7 @@ use OwenIt\Auditing\Contracts\Auditable;
|
|||||||
|
|
||||||
class User extends Authenticatable implements Auditable, MustVerifyEmail
|
class User extends Authenticatable implements Auditable, MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
use \OwenIt\Auditing\Auditable;
|
use \OwenIt\Auditing\Auditable;
|
||||||
@@ -85,6 +86,16 @@ class User extends Authenticatable implements Auditable, MustVerifyEmail
|
|||||||
return $this->belongsTo(UserRole::class, 'role_id');
|
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.
|
* 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\Institution;
|
||||||
use App\Models\InstitutionLink;
|
use App\Models\InstitutionLink;
|
||||||
use App\Models\Lists\InstitutionCategory;
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use App\Models\UserAffiliation;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -86,6 +87,14 @@ class InstitutionTest extends TestCase
|
|||||||
$this->assertCount(1, $institution->refresh()->links);
|
$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
|
public function test_it_is_soft_deleted(): void
|
||||||
{
|
{
|
||||||
$institution = Institution::factory()->create();
|
$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