Compare commits

...

2 Commits

Author SHA1 Message Date
Giuseppe Naponiello
96b0dc76aa user affiliation schema completed 2026-06-24 16:30:57 +02:00
Giuseppe Naponiello
6a07c3e922 user affiliation schema completed 2026-06-24 16:30:21 +02:00
16 changed files with 948 additions and 3 deletions

View File

@@ -77,8 +77,10 @@ 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++;

View 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,
};
}
}

View 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'],
];
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View File

@@ -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);
}
} }

View File

@@ -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.
*/ */

View 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();
}
}

View 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,
];
}
}

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View 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();
});

View File

@@ -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();

View 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);
}
}

View 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();
}
}