feature test su institution
This commit is contained in:
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Etl\Contracts\Importer;
|
||||
use App\Etl\Importers\InstitutionImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ETL idempotente dal DB legacy (v1) allo schema v2.
|
||||
*
|
||||
* Orchestra gli importer in ORDINE DI DIPENDENZA (institutions prima delle entità
|
||||
* che le referenziano, es. artifacts). Ogni importer legge dalla connessione
|
||||
* `legacy` e scrive su quella di default. Serve sia per i refresh di sviluppo
|
||||
* sia per la migrazione di cutover.
|
||||
*/
|
||||
class V1ImportCommand extends Command
|
||||
{
|
||||
protected $signature = 'v1:import
|
||||
{--only=* : Limit to specific importers (e.g. --only=institutions)}
|
||||
{--dry-run : Read and count without writing}';
|
||||
|
||||
protected $description = 'Import data from the legacy (v1) database into the v2 schema (idempotent)';
|
||||
|
||||
/**
|
||||
* Importer in ordine di dipendenza.
|
||||
*
|
||||
* @var list<class-string<Importer>>
|
||||
*/
|
||||
private array $importers = [
|
||||
InstitutionImporter::class,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$only = array_map('strtolower', (array) $this->option('only'));
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Legacy connection unavailable: '.$e->getMessage());
|
||||
$this->line('Set DB_LEGACY_* in the environment (see config/database.php → connections.legacy).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN: no data will be written.');
|
||||
}
|
||||
|
||||
$hadError = false;
|
||||
|
||||
foreach ($this->importers as $class) {
|
||||
/** @var Importer $importer */
|
||||
$importer = app($class);
|
||||
|
||||
if ($only !== [] && ! in_array($importer->key(), $only, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->info("→ {$importer->label()}");
|
||||
|
||||
try {
|
||||
$summary = $importer->import($dryRun);
|
||||
} catch (Throwable $e) {
|
||||
$this->error(" {$importer->key()} failed: ".$e->getMessage());
|
||||
$hadError = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($summary->warnings as $warning) {
|
||||
$this->warn(' ! '.$warning);
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' %screated %d, updated %d, skipped %d (total %d)',
|
||||
$dryRun ? '[dry-run] ' : '',
|
||||
$summary->created,
|
||||
$summary->updated,
|
||||
$summary->skipped,
|
||||
$summary->total(),
|
||||
));
|
||||
}
|
||||
|
||||
return $hadError ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
33
backend/app/Enums/InstitutionLinkType.php
Normal file
33
backend/app/Enums/InstitutionLinkType.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Tipologia di risorsa web collegata a un'istituzione.
|
||||
*
|
||||
* Vocabolario stabile, controllato dallo sviluppatore: backed enum (non MySQL
|
||||
* ENUM né lookup table). Gli artifact useranno un enum separato (ArtifactLinkType)
|
||||
* con un proprio vocabolario.
|
||||
*/
|
||||
enum InstitutionLinkType: string
|
||||
{
|
||||
case Official = 'official';
|
||||
case Ticketing = 'ticketing';
|
||||
case Social = 'social';
|
||||
case Catalogue = 'catalogue';
|
||||
case Other = 'other';
|
||||
|
||||
/**
|
||||
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Official => 'Official website',
|
||||
self::Ticketing => 'Online ticketing',
|
||||
self::Social => 'Social media',
|
||||
self::Catalogue => 'Online catalogue',
|
||||
self::Other => 'Other resource',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
backend/app/Etl/Contracts/Importer.php
Normal file
31
backend/app/Etl/Contracts/Importer.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl\Contracts;
|
||||
|
||||
use App\Etl\ImportSummary;
|
||||
|
||||
/**
|
||||
* Un importer ETL trasforma una porzione del DB legacy (v1) nello schema v2.
|
||||
*
|
||||
* Contratto comune a tutti gli importer (institutions, poi artifacts, ...): il
|
||||
* comando `v1:import` li orchestra in ordine di dipendenza. Ogni import deve
|
||||
* essere IDEMPOTENTE (upsert su `legacy_id`) e leggere SOLO dalla connessione
|
||||
* `legacy`, scrivere SOLO sulla connessione di default (v2).
|
||||
*/
|
||||
interface Importer
|
||||
{
|
||||
/**
|
||||
* Chiave breve per il filtro `--only` (es. "institutions").
|
||||
*/
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* Etichetta leggibile per l'output del comando.
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Esegue l'import. In dry-run non scrive nulla, ma calcola comunque i conteggi.
|
||||
*/
|
||||
public function import(bool $dryRun): ImportSummary;
|
||||
}
|
||||
28
backend/app/Etl/ImportSummary.php
Normal file
28
backend/app/Etl/ImportSummary.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl;
|
||||
|
||||
/**
|
||||
* Esito di un import: conteggi e avvisi non bloccanti (da stampare nel comando).
|
||||
*/
|
||||
class ImportSummary
|
||||
{
|
||||
public int $created = 0;
|
||||
|
||||
public int $updated = 0;
|
||||
|
||||
public int $skipped = 0;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $warnings = [];
|
||||
|
||||
public function warn(string $message): void
|
||||
{
|
||||
$this->warnings[] = $message;
|
||||
}
|
||||
|
||||
public function total(): int
|
||||
{
|
||||
return $this->created + $this->updated + $this->skipped;
|
||||
}
|
||||
}
|
||||
160
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
160
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl\Importers;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Etl\Contracts\Importer;
|
||||
use App\Etl\ImportSummary;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Importa le istituzioni da v1 (`institution`) nello schema v2.
|
||||
*
|
||||
* - Upsert su `legacy_id` → idempotente (re-run aggiorna, non duplica).
|
||||
* - `category` (v1) → `category_id` (v2): mappa 1:1 perché il lookup è seedato con
|
||||
* gli stessi id legacy. Categoria assente nel lookup (es. 1 "uncategorized") → skip + warning.
|
||||
* - `uuid` NON copiato dal legacy: lo rigenera il model (orderedUuid) alla creazione.
|
||||
* - `url` (campo singolo v1) → riga `institution_links` con type=official, solo se valorizzato.
|
||||
* - Pulizia dati: trim su name/abbreviation/address/city/logo.
|
||||
*
|
||||
* NB l'auditing è già off in console (audit.console=false), quindi l'import non genera audit.
|
||||
* NB la migrazione dei FILE logo (da v1 a storage/app/public) è fuori scope di questa fetta:
|
||||
* qui si copia solo il valore stringa della colonna.
|
||||
*/
|
||||
class InstitutionImporter implements Importer
|
||||
{
|
||||
private const SOURCE = 'legacy';
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'institutions';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Institutions';
|
||||
}
|
||||
|
||||
public function import(bool $dryRun): ImportSummary
|
||||
{
|
||||
$summary = new ImportSummary;
|
||||
|
||||
/** @var list<int> $validCategoryIds */
|
||||
$validCategoryIds = InstitutionCategory::query()->pluck('id')->map(intval(...))->all();
|
||||
|
||||
if ($validCategoryIds === []) {
|
||||
$summary->warn('Lookup institution_categories vuoto: esegui prima `db:seed`. Import saltato.');
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$rows = DB::connection(self::SOURCE)
|
||||
->table('institution')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($rows, $dryRun, $summary, $validCategoryIds): void {
|
||||
foreach ($rows as $row) {
|
||||
$this->importRow($row, $dryRun, $validCategoryIds, $summary);
|
||||
}
|
||||
});
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Importa (upsert) una singola istituzione legacy. Categoria assente nel
|
||||
* lookup v2 → skip con warning.
|
||||
*
|
||||
* @param list<int> $validCategoryIds
|
||||
*/
|
||||
private function importRow(object $row, bool $dryRun, array $validCategoryIds, ImportSummary $summary): void
|
||||
{
|
||||
$categoryId = (int) $row->category;
|
||||
|
||||
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,
|
||||
));
|
||||
$summary->skipped++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Institution::query()->where('legacy_id', (int) $row->id)->exists()
|
||||
? $summary->updated++
|
||||
: $summary->created++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$institution = Institution::updateOrCreate(
|
||||
['legacy_id' => (int) $row->id],
|
||||
$this->mapAttributes($row, $categoryId),
|
||||
);
|
||||
|
||||
$institution->wasRecentlyCreated ? $summary->created++ : $summary->updated++;
|
||||
|
||||
$this->syncOfficialLink($institution, (string) ($row->url ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappa una riga legacy sugli attributi del model v2 (con pulizia dei dati).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapAttributes(object $row, int $categoryId): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $categoryId,
|
||||
'name' => trim((string) $row->name),
|
||||
'abbreviation' => trim((string) $row->abbreviation),
|
||||
'address' => trim((string) $row->address),
|
||||
'city' => trim((string) $row->city),
|
||||
'lat' => $row->lat,
|
||||
'lon' => $row->lon,
|
||||
'logo' => trim((string) $row->logo),
|
||||
'color' => $this->cleanColor($row->color),
|
||||
'is_storage_place' => (bool) $row->is_storage_place,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Colore valido o fallback al default v2.
|
||||
*/
|
||||
private function cleanColor(mixed $color): string
|
||||
{
|
||||
$color = trim((string) ($color ?? ''));
|
||||
|
||||
return $color !== '' ? $color : '#c5cae9';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizza il link "official" dall'unico `url` legacy. Idempotente
|
||||
* (upsert su institution_id + type). URL vuoto/blank → nessun link.
|
||||
*/
|
||||
private function syncOfficialLink(Institution $institution, string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
InstitutionLink::updateOrCreate(
|
||||
[
|
||||
'institution_id' => $institution->id,
|
||||
'type' => InstitutionLinkType::Official,
|
||||
],
|
||||
[
|
||||
'url' => $url,
|
||||
'sort_order' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstitutionCategoryRequest;
|
||||
use App\Http\Requests\UpdateInstitutionCategoryRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* CRUD del lookup categorie di istituzione (scrittura riservata agli Admin).
|
||||
*
|
||||
* NB gli id 2/3/4/6 sono allineati al legacy e usati dall'ETL: cancellarne uno
|
||||
* in uso è bloccato (409). Nessun soft delete su questo lookup.
|
||||
*/
|
||||
class InstitutionCategoryController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco delle categorie.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->collectionResponse(InstitutionCategory::orderBy('value')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una categoria.
|
||||
*/
|
||||
public function store(StoreInstitutionCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$category = InstitutionCategory::create($request->validated());
|
||||
|
||||
return $this->createdResponse($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di una categoria.
|
||||
*/
|
||||
public function show(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
return $this->okResponse($institutionCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna una categoria.
|
||||
*/
|
||||
public function update(UpdateInstitutionCategoryRequest $request, InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
$institutionCategory->update($request->validated());
|
||||
|
||||
return $this->updatedResponse($institutionCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una categoria (se non assegnata ad alcuna istituzione).
|
||||
*/
|
||||
public function destroy(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
if ($institutionCategory->isInUse()) {
|
||||
return $this->conflictResponse('Cannot delete: category assigned to at least one institution.');
|
||||
}
|
||||
|
||||
$institutionCategory->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indica se la categoria è in uso (bloccata per l'eliminazione).
|
||||
*/
|
||||
public function usage(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'in_use' => $institutionCategory->isInUse(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\IndexInstitutionRequest;
|
||||
use App\Http\Requests\StoreInstitutionRequest;
|
||||
use App\Http\Requests\UpdateInstitutionRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Institution;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* CRUD delle istituzioni (scrittura riservata agli Admin via tier di rotta).
|
||||
*
|
||||
* Identificate verso l'esterno dallo `uuid` (route key), non dall'id interno.
|
||||
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
|
||||
* definitivamente (e rimuove il file logo + i link via cascade FK).
|
||||
*/
|
||||
class InstitutionController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/** Sottocartella del disco `public` dove vivono i loghi. */
|
||||
private const LOGO_DIR = 'institution_logo';
|
||||
|
||||
/**
|
||||
* Elenco paginato, con filtri opzionali (ricerca, categoria, cestino).
|
||||
*/
|
||||
public function index(IndexInstitutionRequest $request): JsonResponse
|
||||
{
|
||||
$query = Institution::query()
|
||||
->with('category')
|
||||
->orderBy('name');
|
||||
|
||||
$this->applyTrashed($query, $request->input('trashed'));
|
||||
$this->applySearch($query, $request->input('search'));
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->integer('category_id'));
|
||||
}
|
||||
|
||||
$institutions = $query->paginate($request->integer('per_page') ?: 20);
|
||||
|
||||
return $this->paginatedCollectionResponse($institutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un'istituzione (con upload del logo).
|
||||
*/
|
||||
public function store(StoreInstitutionRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||
|
||||
$institution = Institution::create($data);
|
||||
|
||||
return $this->createdResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio (con categoria e link).
|
||||
*/
|
||||
public function show(Institution $institution): JsonResponse
|
||||
{
|
||||
return $this->okResponse($institution->load(['category', 'links']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna un'istituzione. Logo opzionale: se presente sostituisce il vecchio.
|
||||
*/
|
||||
public function update(UpdateInstitutionRequest $request, Institution $institution): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$this->deleteLogo($institution->logo);
|
||||
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||
} else {
|
||||
unset($data['logo']);
|
||||
}
|
||||
|
||||
$institution->update($data);
|
||||
|
||||
return $this->updatedResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cestina (soft delete).
|
||||
*/
|
||||
public function destroy(Institution $institution): JsonResponse
|
||||
{
|
||||
$institution->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ripristina un'istituzione cestinata.
|
||||
*/
|
||||
public function restore(Institution $institution): JsonResponse
|
||||
{
|
||||
$institution->restore();
|
||||
|
||||
return $this->restoredResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina definitivamente (rimuove il file logo; i link cadono in cascata DB).
|
||||
*/
|
||||
public function forceDestroy(Institution $institution): JsonResponse
|
||||
{
|
||||
$this->deleteLogo($institution->logo);
|
||||
$institution->forceDelete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva il file logo sul disco `public` e ne restituisce il path relativo.
|
||||
*/
|
||||
private function storeLogo(UploadedFile $file): string
|
||||
{
|
||||
return $file->store(self::LOGO_DIR, 'public');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rimuove un file logo dal disco `public`, se presente.
|
||||
*/
|
||||
private function deleteLogo(?string $path): void
|
||||
{
|
||||
if (filled($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
|
||||
*
|
||||
* @param Builder<Institution> $query
|
||||
*/
|
||||
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||
{
|
||||
match ($trashed) {
|
||||
'with' => $query->withTrashed(),
|
||||
'only' => $query->onlyTrashed(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ricerca parziale su nome, città e sigla.
|
||||
*
|
||||
* @param Builder<Institution> $query
|
||||
*/
|
||||
private function applySearch(Builder $query, ?string $search): void
|
||||
{
|
||||
if (blank($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$term = '%'.$search.'%';
|
||||
|
||||
$query->where(function (Builder $q) use ($term): void {
|
||||
$q->where('name', 'like', $term)
|
||||
->orWhere('city', 'like', $term)
|
||||
->orWhere('abbreviation', 'like', $term);
|
||||
});
|
||||
}
|
||||
}
|
||||
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstitutionLinkRequest;
|
||||
use App\Http\Requests\UpdateInstitutionLinkRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Risorse web di un'istituzione (sito ufficiale, ticketing, social, ...).
|
||||
*
|
||||
* Risorsa annidata e scoped sotto `institutions/{institution}`: il link deve
|
||||
* appartenere all'istituzione del path. Nessun soft delete (solo Institution lo ha).
|
||||
*/
|
||||
class InstitutionLinkController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco dei link di un'istituzione, ordinati.
|
||||
*/
|
||||
public function index(Institution $institution): JsonResponse
|
||||
{
|
||||
$links = $institution->links()->orderBy('sort_order')->get();
|
||||
|
||||
return $this->collectionResponse($links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un link all'istituzione.
|
||||
*/
|
||||
public function store(StoreInstitutionLinkRequest $request, Institution $institution): JsonResponse
|
||||
{
|
||||
$link = $institution->links()->create($request->validated());
|
||||
|
||||
return $this->createdResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un link.
|
||||
*/
|
||||
public function show(Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
return $this->okResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna un link.
|
||||
*/
|
||||
public function update(UpdateInstitutionLinkRequest $request, Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
$link->update($request->validated());
|
||||
|
||||
return $this->updatedResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un link.
|
||||
*/
|
||||
public function destroy(Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
$link->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
}
|
||||
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Filtri di query per l'elenco istituzioni. Autorizzazione demandata ai
|
||||
* middleware di rotta (tier.app).
|
||||
*/
|
||||
class IndexInstitutionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'category_id' => ['nullable', 'integer', 'exists:institution_categories,id'],
|
||||
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstitutionCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 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 [
|
||||
'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreInstitutionLinkRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing(['sort_order' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:2000'],
|
||||
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstitutionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default per i campi con valore di colonna (color, is_storage_place):
|
||||
* così non si inseriscono mai NULL su colonne NOT NULL.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing([
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255', 'unique:institutions,name'],
|
||||
'abbreviation' => ['required', 'string', 'max:5', 'unique:institutions,abbreviation'],
|
||||
'address' => ['required', 'string', 'max:255'],
|
||||
'city' => ['required', 'string', 'max:100'],
|
||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||
'logo' => ['required', 'image', 'max:5120'],
|
||||
'color' => ['required', 'string', 'max:50'],
|
||||
'is_storage_place' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$id = $this->route('institution_category')->id;
|
||||
|
||||
return [
|
||||
'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionLinkRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing(['sort_order' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:2000'],
|
||||
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$id = $this->route('institution')->id;
|
||||
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('institutions', 'name')->ignore($id)],
|
||||
'abbreviation' => ['required', 'string', 'max:5', Rule::unique('institutions', 'abbreviation')->ignore($id)],
|
||||
'address' => ['required', 'string', 'max:255'],
|
||||
'city' => ['required', 'string', 'max:100'],
|
||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||
// Opzionale in update: se assente si mantiene il logo esistente.
|
||||
'logo' => ['nullable', 'image', 'max:5120'],
|
||||
'color' => ['required', 'string', 'max:50'],
|
||||
'is_storage_place' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
90
backend/app/Models/Institution.php
Normal file
90
backend/app/Models/Institution.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class Institution extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'name',
|
||||
'abbreviation',
|
||||
'address',
|
||||
'city',
|
||||
'lat',
|
||||
'lon',
|
||||
'logo',
|
||||
'color',
|
||||
'is_storage_place',
|
||||
'legacy_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Identificatore esterno stabile, esposto nelle rotte pubbliche/LOD e usato
|
||||
* come token nei canonical URI Linked Art. NON fillable: generato qui sotto
|
||||
* (o impostato esplicitamente dall'ETL), mai via mass assignment.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Institution $institution): void {
|
||||
if (empty($institution->uuid)) {
|
||||
$institution->uuid = (string) Str::orderedUuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => 'integer',
|
||||
'lat' => 'float',
|
||||
'lon' => 'float',
|
||||
'is_storage_place' => 'boolean',
|
||||
'legacy_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* La rotta pubblica risolve per uuid, non per id auto-increment.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Categoria dell'istituzione.
|
||||
*
|
||||
* @return BelongsTo<InstitutionCategory, $this>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InstitutionCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Risorse web collegate (sito ufficiale, ticketing, social, ...).
|
||||
*
|
||||
* @return HasMany<InstitutionLink, $this>
|
||||
*/
|
||||
public function links(): HasMany
|
||||
{
|
||||
return $this->hasMany(InstitutionLink::class);
|
||||
}
|
||||
}
|
||||
46
backend/app/Models/InstitutionLink.php
Normal file
46
backend/app/Models/InstitutionLink.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class InstitutionLink extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'institution_id',
|
||||
'url',
|
||||
'type',
|
||||
'label',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'institution_id' => 'integer',
|
||||
'type' => InstitutionLinkType::class,
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Istituzione a cui appartiene il link.
|
||||
*
|
||||
* @return BelongsTo<Institution, $this>
|
||||
*/
|
||||
public function institution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Institution::class);
|
||||
}
|
||||
}
|
||||
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Lists;
|
||||
|
||||
use App\Models\Institution;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
/**
|
||||
* Lookup delle categorie di istituzione.
|
||||
*
|
||||
* Tabella controllata: gli id sono fissi e coincidono con quelli del DB legacy
|
||||
* (v1) così l'ETL mappa institutions.category_id 1:1 senza tradurre. La categoria
|
||||
* legacy 1 ("uncategorized", mai usata) NON viene riportata.
|
||||
*/
|
||||
class InstitutionCategory extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
// Id allineati al legacy (vedi seeder). Nessun id 1 e nessun id 5.
|
||||
public const LIBRARY = 'library';
|
||||
|
||||
public const MUSEUM = 'museum';
|
||||
|
||||
public const PUBLIC_ADMINISTRATION = 'public administration';
|
||||
|
||||
public const RESEARCH_INSTITUTE = 'research institute';
|
||||
|
||||
protected $table = 'institution_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'value',
|
||||
];
|
||||
|
||||
/**
|
||||
* Istituzioni che appartengono a questa categoria.
|
||||
*
|
||||
* @return HasMany<Institution, $this>
|
||||
*/
|
||||
public function institutions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Institution::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* La categoria è assegnata ad almeno un'istituzione.
|
||||
*/
|
||||
public function isInUse(): bool
|
||||
{
|
||||
return $this->institutions()->exists();
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'mysql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -84,6 +84,31 @@ return [
|
||||
]) : [],
|
||||
],
|
||||
|
||||
/*
|
||||
* Sorgente legacy (dyncoll v1) in sola lettura per l'ETL `php artisan v1:import`.
|
||||
* Le DB_LEGACY_* NON stanno in .env.example: si impostano nell'ambiente che esegue
|
||||
* l'import (dev: dump di produzione restorato in locale; cutover: DB v1 reale).
|
||||
* `strict=false` per tollerare il sql_mode del DB v1.
|
||||
*/
|
||||
'legacy' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_LEGACY_URL'),
|
||||
'host' => env('DB_LEGACY_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_LEGACY_PORT', '3306'),
|
||||
'database' => env('DB_LEGACY_DATABASE', 'dyncoll'),
|
||||
'username' => env('DB_LEGACY_USERNAME', 'root'),
|
||||
'password' => env('DB_LEGACY_PASSWORD', ''),
|
||||
'charset' => env('DB_LEGACY_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_LEGACY_COLLATION', 'utf8mb4_0900_ai_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('DB_LEGACY_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
|
||||
34
backend/database/factories/InstitutionFactory.php
Normal file
34
backend/database/factories/InstitutionFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Institution>
|
||||
*/
|
||||
class InstitutionFactory extends Factory
|
||||
{
|
||||
protected $model = Institution::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => InstitutionCategory::factory(),
|
||||
'name' => fake()->unique()->company(),
|
||||
'abbreviation' => strtoupper(fake()->unique()->lexify('?????')),
|
||||
'address' => fake()->streetAddress(),
|
||||
'city' => fake()->city(),
|
||||
'lat' => fake()->latitude(),
|
||||
'lon' => fake()->longitude(),
|
||||
'logo' => 'default.jpg',
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<InstitutionLink>
|
||||
*/
|
||||
class InstitutionLinkFactory extends Factory
|
||||
{
|
||||
protected $model = InstitutionLink::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'institution_id' => Institution::factory(),
|
||||
'url' => fake()->url(),
|
||||
'type' => InstitutionLinkType::Official,
|
||||
'label' => null,
|
||||
'sort_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Lists;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<InstitutionCategory>
|
||||
*/
|
||||
class InstitutionCategoryFactory extends Factory
|
||||
{
|
||||
protected $model = InstitutionCategory::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'value' => fake()->unique()->word(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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('institution_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('value', 25)->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institution_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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('institutions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')
|
||||
->constrained('institution_categories')
|
||||
->cascadeOnUpdate()
|
||||
->restrictOnDelete();
|
||||
$table->string('name')->unique();
|
||||
$table->string('abbreviation', 5)->unique();
|
||||
$table->string('address');
|
||||
$table->string('city', 100)->index();
|
||||
$table->decimal('lat', 10, 6);
|
||||
$table->decimal('lon', 10, 6);
|
||||
$table->string('logo');
|
||||
$table->string('color', 50)->default('#c5cae9');
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->boolean('is_storage_place')->default(true);
|
||||
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
|
||||
// risolvere le FK delle tabelle dipendenti (es. artifact.owner).
|
||||
$table->unsignedBigInteger('legacy_id')->nullable()->unique();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institutions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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('institution_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('institution_id')
|
||||
->constrained()
|
||||
->cascadeOnDelete();
|
||||
$table->string('url', 2000);
|
||||
// Vincolato a App\Enums\InstitutionLinkType (cast + validazione lato app).
|
||||
$table->string('type', 30);
|
||||
$table->string('label', 100)->nullable();
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['institution_id', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institution_links');
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder
|
||||
$this->call([
|
||||
UserRoleSeeder::class,
|
||||
SystemUserSeeder::class,
|
||||
InstitutionCategorySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InstitutionCategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Categorie di istituzione. Idempotente.
|
||||
*
|
||||
* Gli id sono FISSI e coincidono con il DB legacy (v1) così l'ETL mappa
|
||||
* institutions.category_id 1:1 senza tradurre. La categoria 1 ("uncategorized",
|
||||
* mai usata) e la 5 (inesistente nel legacy) sono volutamente assenti.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
2 => InstitutionCategory::LIBRARY,
|
||||
3 => InstitutionCategory::MUSEUM,
|
||||
4 => InstitutionCategory::PUBLIC_ADMINISTRATION,
|
||||
6 => InstitutionCategory::RESEARCH_INSTITUTE,
|
||||
];
|
||||
|
||||
foreach ($categories as $id => $value) {
|
||||
InstitutionCategory::updateOrCreate(
|
||||
['id' => $id],
|
||||
['value' => $value],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
backend/routes/api/institution-categories.php
Normal file
17
backend/routes/api/institution-categories.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstitutionCategoryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e tier applicati dal loader in bootstrap/app.php.
|
||||
|
||||
// Lettura: utente pienamente operativo.
|
||||
Route::middleware('tier.app')->group(function () {
|
||||
Route::apiResource('institution-categories', InstitutionCategoryController::class)->only(['index', 'show']);
|
||||
Route::get('institution-categories/{institution_category}/usage', [InstitutionCategoryController::class, 'usage']);
|
||||
});
|
||||
|
||||
// Scrittura: solo Admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::apiResource('institution-categories', InstitutionCategoryController::class)->only(['store', 'update', 'destroy']);
|
||||
});
|
||||
29
backend/routes/api/institutions.php
Normal file
29
backend/routes/api/institutions.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstitutionController;
|
||||
use App\Http\Controllers\InstitutionLinkController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e tier (`tier.app` / `tier.admin`) applicati dal loader in
|
||||
// bootstrap/app.php. Le istituzioni si risolvono per `uuid` (route key del model).
|
||||
|
||||
// Lettura: utente pienamente operativo.
|
||||
Route::middleware('tier.app')->group(function () {
|
||||
Route::apiResource('institutions', InstitutionController::class)->only(['index', 'show']);
|
||||
Route::apiResource('institutions.links', InstitutionLinkController::class)
|
||||
->only(['index', 'show'])
|
||||
->scoped();
|
||||
});
|
||||
|
||||
// Scrittura + soft delete: solo Admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::apiResource('institutions', InstitutionController::class)->only(['store', 'update', 'destroy']);
|
||||
|
||||
// Soft delete: ripristino e cancellazione definitiva (binding sui cestinati).
|
||||
Route::post('institutions/{institution}/restore', [InstitutionController::class, 'restore'])->withTrashed();
|
||||
Route::delete('institutions/{institution}/force', [InstitutionController::class, 'forceDestroy'])->withTrashed();
|
||||
|
||||
Route::apiResource('institutions.links', InstitutionLinkController::class)
|
||||
->only(['store', 'update', 'destroy'])
|
||||
->scoped();
|
||||
});
|
||||
74
backend/tests/Concerns/CreatesLegacyDatabase.php
Normal file
74
backend/tests/Concerns/CreatesLegacyDatabase.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Costruisce una connessione `legacy` SQLite in-memory che replica le tabelle v1
|
||||
* lette dall'ETL (`institution`), così gli importer si testano senza un MySQL v1.
|
||||
*/
|
||||
trait CreatesLegacyDatabase
|
||||
{
|
||||
/** Riconfigura la connessione `legacy` su SQLite :memory: e ne crea lo schema v1. */
|
||||
protected function fakeLegacyConnection(): void
|
||||
{
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => false,
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
|
||||
$schema = Schema::connection('legacy');
|
||||
$schema->dropIfExists('institution');
|
||||
|
||||
$schema->create('institution', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('id')->primary();
|
||||
$table->unsignedBigInteger('category');
|
||||
$table->string('name', 255);
|
||||
$table->string('abbreviation', 5);
|
||||
$table->string('address', 255);
|
||||
$table->decimal('lat', 10, 6);
|
||||
$table->decimal('lon', 10, 6);
|
||||
$table->string('url', 2000)->nullable();
|
||||
$table->string('logo', 255);
|
||||
$table->string('uuid', 36);
|
||||
$table->string('color', 50)->nullable();
|
||||
$table->string('city', 100);
|
||||
$table->boolean('is_storage_place');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserisce una riga `institution` legacy e ne restituisce l'id.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function insertLegacyInstitution(array $overrides = []): int
|
||||
{
|
||||
$row = array_merge([
|
||||
'id' => fake()->unique()->numberBetween(1, 1_000_000),
|
||||
'category' => 3,
|
||||
'name' => 'Some Museum',
|
||||
'abbreviation' => 'SM',
|
||||
'address' => 'Main Street 1',
|
||||
'lat' => 55.704660,
|
||||
'lon' => 13.191007,
|
||||
'url' => 'https://example.org/',
|
||||
'logo' => 'default.jpg',
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'color' => '#abcdef',
|
||||
'city' => 'Lund',
|
||||
'is_storage_place' => 1,
|
||||
], $overrides);
|
||||
|
||||
DB::connection('legacy')->table('institution')->insert($row);
|
||||
|
||||
return (int) $row['id'];
|
||||
}
|
||||
}
|
||||
168
backend/tests/Feature/Etl/InstitutionImporterTest.php
Normal file
168
backend/tests/Feature/Etl/InstitutionImporterTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Etl;
|
||||
|
||||
use App\Etl\Importers\InstitutionImporter;
|
||||
use App\Models\Institution;
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesLegacyDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionImporterTest extends TestCase
|
||||
{
|
||||
use CreatesLegacyDatabase;
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Categorie v2 con gli id legacy fissi (2,3,4,6). */
|
||||
private function seedV2Categories(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
}
|
||||
|
||||
public function test_it_imports_an_institution_and_creates_the_official_link(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution([
|
||||
'id' => 1,
|
||||
'category' => 3,
|
||||
'name' => 'Blekinge Museum',
|
||||
'abbreviation' => 'BLM',
|
||||
'city' => 'Karlskrona',
|
||||
'url' => 'https://blekingemuseum.se/',
|
||||
]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $summary->created);
|
||||
$this->assertDatabaseHas('institutions', [
|
||||
'legacy_id' => 1,
|
||||
'category_id' => 3,
|
||||
'name' => 'Blekinge Museum',
|
||||
'city' => 'Karlskrona',
|
||||
]);
|
||||
|
||||
$institution = Institution::where('legacy_id', 1)->firstOrFail();
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
$this->assertDatabaseHas('institution_links', [
|
||||
'institution_id' => $institution->id,
|
||||
'type' => 'official',
|
||||
'url' => 'https://blekingemuseum.se/',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_no_link_when_the_url_is_blank(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 21, 'url' => ' ']);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseCount('institution_links', 0);
|
||||
}
|
||||
|
||||
public function test_it_trims_textual_fields(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 20, 'name' => 'Lund University Library ']);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseHas('institutions', ['legacy_id' => 20, 'name' => 'Lund University Library']);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_to_the_default_color_when_legacy_color_is_blank(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1, 'color' => null]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseHas('institutions', ['legacy_id' => 1, 'color' => '#c5cae9']);
|
||||
}
|
||||
|
||||
public function test_it_skips_institutions_with_an_unknown_category(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $summary->skipped);
|
||||
$this->assertNotEmpty($summary->warnings);
|
||||
$this->assertDatabaseMissing('institutions', ['legacy_id' => 99]);
|
||||
}
|
||||
|
||||
public function test_it_regenerates_the_uuid_instead_of_copying_the_legacy_one(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$legacyUuid = '00000000-0000-0000-0000-000000000000';
|
||||
$this->insertLegacyInstitution(['id' => 1, 'uuid' => $legacyUuid]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertNotSame($legacyUuid, Institution::where('legacy_id', 1)->value('uuid'));
|
||||
}
|
||||
|
||||
public function test_it_is_idempotent_and_keeps_the_uuid_stable(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$first = (new InstitutionImporter)->import(false);
|
||||
$uuid = Institution::where('legacy_id', 1)->value('uuid');
|
||||
$second = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $first->created);
|
||||
$this->assertSame(0, $second->created);
|
||||
$this->assertSame(1, $second->updated);
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
$this->assertSame($uuid, Institution::where('legacy_id', 1)->value('uuid'));
|
||||
}
|
||||
|
||||
public function test_dry_run_writes_nothing(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(true);
|
||||
|
||||
$this->assertSame(1, $summary->created);
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_dry_run_counts_an_existing_institution_as_updated(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
$summary = (new InstitutionImporter)->import(true);
|
||||
|
||||
$this->assertSame(0, $summary->created);
|
||||
$this->assertSame(1, $summary->updated);
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
}
|
||||
|
||||
public function test_it_warns_and_skips_when_the_v2_lookup_is_empty(): void
|
||||
{
|
||||
// Nessun seed delle categorie v2.
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertNotEmpty($summary->warnings);
|
||||
$this->assertSame(0, $summary->total());
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
}
|
||||
94
backend/tests/Feature/Etl/V1ImportCommandTest.php
Normal file
94
backend/tests/Feature/Etl/V1ImportCommandTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Etl;
|
||||
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\CreatesLegacyDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class V1ImportCommandTest extends TestCase
|
||||
{
|
||||
use CreatesLegacyDatabase;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_imports_and_reports_counts(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('created 1, updated 0, skipped 0')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
}
|
||||
|
||||
public function test_dry_run_writes_nothing(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import --dry-run')
|
||||
->expectsOutputToContain('DRY-RUN')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_only_filter_skips_unmatched_importers(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import --only=nonexistent')->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_it_prints_importer_warnings(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('category 999')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_it_fails_when_an_importer_throws(): void
|
||||
{
|
||||
// Connessione raggiungibile (getPdo ok) ma senza la tabella `institution`:
|
||||
// l'import lancia, il comando intercetta e fallisce.
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('failed')
|
||||
->assertFailed();
|
||||
}
|
||||
|
||||
public function test_it_fails_when_the_legacy_connection_is_unavailable(): void
|
||||
{
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => '/nonexistent/path/legacy.sqlite',
|
||||
'prefix' => '',
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('Legacy connection unavailable')
|
||||
->assertFailed();
|
||||
}
|
||||
}
|
||||
110
backend/tests/Feature/InstitutionCategoryControllerTest.php
Normal file
110
backend/tests/Feature/InstitutionCategoryControllerTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionCategoryControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_categories(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
InstitutionCategory::factory()->count(2)->create();
|
||||
|
||||
$this->getJson('/api/institution-categories')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['message', 'data' => [['id', 'value']]])
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_show_returns_a_single_category(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->getJson("/api/institution-categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $category->id);
|
||||
}
|
||||
|
||||
public function test_usage_reflects_whether_the_category_is_used(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$free = InstitutionCategory::factory()->create();
|
||||
$used = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $used->id]);
|
||||
|
||||
$this->getJson("/api/institution-categories/{$free->id}/usage")->assertJsonPath('in_use', false);
|
||||
$this->getJson("/api/institution-categories/{$used->id}/usage")->assertJsonPath('in_use', true);
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_create_a_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.value', 'gallery');
|
||||
|
||||
$this->assertDatabaseHas('institution_categories', ['value' => 'gallery']);
|
||||
}
|
||||
|
||||
public function test_create_rejects_a_duplicate_value(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
InstitutionCategory::factory()->create(['value' => 'gallery']);
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('value');
|
||||
}
|
||||
|
||||
public function test_admin_can_update_a_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->putJson("/api/institution-categories/{$category->id}", ['value' => 'archive'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.value', 'archive');
|
||||
}
|
||||
|
||||
public function test_cannot_delete_a_category_in_use(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $category->id]);
|
||||
|
||||
$this->deleteJson("/api/institution-categories/{$category->id}")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('in_use', true);
|
||||
|
||||
$this->assertDatabaseHas('institution_categories', ['id' => $category->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_a_free_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->deleteJson("/api/institution-categories/{$category->id}")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institution_categories', ['id' => $category->id]);
|
||||
}
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'x'])->assertForbidden();
|
||||
}
|
||||
}
|
||||
40
backend/tests/Feature/InstitutionCategorySeederTest.php
Normal file
40
backend/tests/Feature/InstitutionCategorySeederTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionCategorySeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_seeds_the_categories_with_fixed_legacy_ids(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertSame(4, InstitutionCategory::count());
|
||||
$this->assertSame('library', InstitutionCategory::find(2)?->value);
|
||||
$this->assertSame('museum', InstitutionCategory::find(3)?->value);
|
||||
$this->assertSame('public administration', InstitutionCategory::find(4)?->value);
|
||||
$this->assertSame('research institute', InstitutionCategory::find(6)?->value);
|
||||
}
|
||||
|
||||
public function test_it_excludes_uncategorized_and_the_gap_ids(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertNull(InstitutionCategory::find(1));
|
||||
$this->assertNull(InstitutionCategory::find(5));
|
||||
}
|
||||
|
||||
public function test_it_is_idempotent(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertSame(4, InstitutionCategory::count());
|
||||
}
|
||||
}
|
||||
231
backend/tests/Feature/InstitutionControllerTest.php
Normal file
231
backend/tests/Feature/InstitutionControllerTest.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'category_id' => InstitutionCategory::factory()->create()->id,
|
||||
'name' => 'Test Museum',
|
||||
'abbreviation' => 'TM',
|
||||
'address' => 'Some Road 1',
|
||||
'city' => 'Lund',
|
||||
'lat' => 55.7,
|
||||
'lon' => 13.19,
|
||||
'color' => '#ffffff',
|
||||
'is_storage_place' => true,
|
||||
'logo' => UploadedFile::fake()->image('logo.png'),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_institutions_for_an_operational_user(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
Institution::factory()->count(2)->create();
|
||||
|
||||
$this->getJson('/api/institutions')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['message', 'data', 'meta' => ['current_page', 'total']])
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_index_filters_by_category_and_search(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $category->id, 'name' => 'Blekinge Museum']);
|
||||
Institution::factory()->create(['name' => 'Other Place']);
|
||||
|
||||
$this->getJson("/api/institutions?category_id={$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.name', 'Blekinge Museum');
|
||||
|
||||
$this->getJson('/api/institutions?search=Blekinge')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
}
|
||||
|
||||
public function test_index_can_list_only_trashed(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$trashed = Institution::factory()->create();
|
||||
Institution::factory()->create();
|
||||
$trashed->delete();
|
||||
|
||||
$this->getJson('/api/institutions')->assertJsonCount(1, 'data');
|
||||
$this->getJson('/api/institutions?trashed=only')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $trashed->id);
|
||||
$this->getJson('/api/institutions?trashed=with')->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_show_returns_institution_with_category_and_links(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.uuid', $institution->uuid)
|
||||
->assertJsonStructure(['data' => ['category', 'links']]);
|
||||
}
|
||||
|
||||
public function test_show_resolves_by_uuid_not_id(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
// L'id interno non è una route key valida.
|
||||
$this->getJson("/api/institutions/{$institution->id}")->assertNotFound();
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_create_an_institution_with_a_logo(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->post('/api/institutions', $this->validPayload(), ['Accept' => 'application/json'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.name', 'Test Museum')
|
||||
->assertJsonStructure(['data' => ['uuid', 'category']]);
|
||||
|
||||
$institution = Institution::firstWhere('name', 'Test Museum');
|
||||
$this->assertNotNull($institution);
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
Storage::disk('public')->assertExists($institution->logo);
|
||||
}
|
||||
|
||||
public function test_create_applies_default_color_and_storage_flag(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$payload = $this->validPayload();
|
||||
unset($payload['color'], $payload['is_storage_place']);
|
||||
|
||||
$this->post('/api/institutions', $payload, ['Accept' => 'application/json'])->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('institutions', [
|
||||
'name' => 'Test Museum',
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_validates_required_fields(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->postJson('/api/institutions', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['category_id', 'name', 'abbreviation', 'lat', 'lon', 'logo']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_an_institution_keeping_the_logo(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create(['logo' => 'institution_logo/keep.jpg', 'name' => 'Old']);
|
||||
|
||||
$payload = $this->validPayload(['name' => 'New Name', 'category_id' => $institution->category_id]);
|
||||
unset($payload['logo']);
|
||||
|
||||
$this->put("/api/institutions/{$institution->uuid}", $payload, ['Accept' => 'application/json'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.name', 'New Name');
|
||||
|
||||
$this->assertSame('institution_logo/keep.jpg', $institution->fresh()->logo);
|
||||
}
|
||||
|
||||
public function test_update_replaces_the_logo_when_a_new_one_is_uploaded(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
Storage::disk('public')->put('institution_logo/old.jpg', 'x');
|
||||
$institution = Institution::factory()->create(['logo' => 'institution_logo/old.jpg']);
|
||||
|
||||
$payload = $this->validPayload(['category_id' => $institution->category_id]);
|
||||
|
||||
$this->put("/api/institutions/{$institution->uuid}", $payload, ['Accept' => 'application/json'])->assertOk();
|
||||
|
||||
Storage::disk('public')->assertMissing('institution_logo/old.jpg');
|
||||
Storage::disk('public')->assertExists($institution->fresh()->logo);
|
||||
}
|
||||
|
||||
public function test_admin_can_soft_delete_an_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->deleteJson("/api/institutions/{$institution->uuid}")->assertOk();
|
||||
|
||||
$this->assertSoftDeleted($institution);
|
||||
}
|
||||
|
||||
public function test_admin_can_restore_a_trashed_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$institution->delete();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/restore")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.uuid', $institution->uuid);
|
||||
|
||||
$this->assertNotSoftDeleted($institution);
|
||||
}
|
||||
|
||||
public function test_admin_can_force_delete_an_institution_and_its_logo_and_links(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
Storage::disk('public')->put('institution_logo/gone.jpg', 'x');
|
||||
$institution = Institution::factory()->create(['logo' => 'institution_logo/gone.jpg']);
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
$institution->delete();
|
||||
|
||||
$this->deleteJson("/api/institutions/{$institution->uuid}/force")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institutions', ['id' => $institution->id]);
|
||||
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
|
||||
Storage::disk('public')->assertMissing('institution_logo/gone.jpg');
|
||||
}
|
||||
|
||||
// --- Autorizzazione ------------------------------------------------------
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson('/api/institutions', [])->assertForbidden();
|
||||
$this->deleteJson("/api/institutions/{$institution->uuid}")->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_access_institutions(): void
|
||||
{
|
||||
$this->getJson('/api/institutions')->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
149
backend/tests/Feature/InstitutionLinkControllerTest.php
Normal file
149
backend/tests/Feature/InstitutionLinkControllerTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionLinkControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'url' => 'https://example.org',
|
||||
'type' => InstitutionLinkType::Official->value,
|
||||
'label' => 'Official site',
|
||||
'sort_order' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_links_of_an_institution_ordered(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$second = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 2]);
|
||||
$first = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 1]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links")
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $first->id)
|
||||
->assertJsonPath('data.1.id', $second->id);
|
||||
}
|
||||
|
||||
public function test_show_returns_a_single_link(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $link->id);
|
||||
}
|
||||
|
||||
public function test_show_returns_not_found_when_the_link_belongs_to_another_institution(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$otherInstitution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $otherInstitution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertNotFound();
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_add_a_link_to_an_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.url', 'https://example.org')
|
||||
->assertJsonPath('data.institution_id', $institution->id);
|
||||
|
||||
$this->assertDatabaseHas('institution_links', [
|
||||
'institution_id' => $institution->id,
|
||||
'url' => 'https://example.org',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_defaults_sort_order_to_zero_when_missing(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$payload = $this->validPayload();
|
||||
unset($payload['sort_order']);
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.sort_order', 0);
|
||||
}
|
||||
|
||||
public function test_store_validates_required_fields(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['url', 'type']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_a_link(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->putJson(
|
||||
"/api/institutions/{$institution->uuid}/links/{$link->id}",
|
||||
$this->validPayload(['url' => 'https://updated.example.org'])
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.url', 'https://updated.example.org');
|
||||
|
||||
$this->assertSame('https://updated.example.org', $link->fresh()->url);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_a_link(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->deleteJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
|
||||
}
|
||||
|
||||
// --- Autorizzazione ------------------------------------------------------
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_access_links(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links")->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
30
backend/tests/Feature/Models/InstitutionLinkTest.php
Normal file
30
backend/tests/Feature/Models/InstitutionLinkTest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionLinkTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_type_is_cast_to_enum(): void
|
||||
{
|
||||
$link = InstitutionLink::factory()->create(['type' => InstitutionLinkType::Ticketing]);
|
||||
|
||||
$this->assertInstanceOf(InstitutionLinkType::class, $link->fresh()->type);
|
||||
$this->assertSame(InstitutionLinkType::Ticketing, $link->fresh()->type);
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_an_institution(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->assertTrue($link->institution->is($institution));
|
||||
}
|
||||
}
|
||||
99
backend/tests/Feature/Models/InstitutionTest.php
Normal file
99
backend/tests/Feature/Models/InstitutionTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_uuid_is_generated_on_create(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
$this->assertSame(36, strlen((string) $institution->uuid));
|
||||
}
|
||||
|
||||
public function test_two_institutions_get_distinct_uuids(): void
|
||||
{
|
||||
$a = Institution::factory()->create();
|
||||
$b = Institution::factory()->create();
|
||||
|
||||
$this->assertNotSame($a->uuid, $b->uuid);
|
||||
}
|
||||
|
||||
public function test_a_provided_uuid_is_not_overwritten(): void
|
||||
{
|
||||
$uuid = (string) Str::uuid();
|
||||
|
||||
$institution = Institution::factory()->make();
|
||||
$institution->uuid = $uuid;
|
||||
$institution->save();
|
||||
|
||||
$this->assertSame($uuid, $institution->fresh()->uuid);
|
||||
}
|
||||
|
||||
public function test_route_key_is_the_uuid(): void
|
||||
{
|
||||
$this->assertSame('uuid', (new Institution)->getRouteKeyName());
|
||||
}
|
||||
|
||||
public function test_attributes_are_cast(): void
|
||||
{
|
||||
$institution = Institution::factory()->create([
|
||||
'is_storage_place' => 1,
|
||||
'lat' => '55.700000',
|
||||
'lon' => '13.190000',
|
||||
'legacy_id' => '42',
|
||||
]);
|
||||
|
||||
$fresh = $institution->fresh();
|
||||
|
||||
$this->assertIsBool($fresh->is_storage_place);
|
||||
$this->assertIsFloat($fresh->lat);
|
||||
$this->assertIsFloat($fresh->lon);
|
||||
$this->assertSame(42, $fresh->legacy_id);
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_a_category(): void
|
||||
{
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
$institution = Institution::factory()->create(['category_id' => $category->id]);
|
||||
|
||||
$this->assertTrue($institution->category->is($category));
|
||||
}
|
||||
|
||||
public function test_a_category_has_many_institutions(): void
|
||||
{
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->count(2)->create(['category_id' => $category->id]);
|
||||
|
||||
$this->assertCount(2, $category->institutions);
|
||||
}
|
||||
|
||||
public function test_it_has_many_links(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->assertCount(1, $institution->refresh()->links);
|
||||
}
|
||||
|
||||
public function test_it_is_soft_deleted(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$institution->delete();
|
||||
|
||||
$this->assertSoftDeleted($institution);
|
||||
$this->assertSame(0, Institution::count());
|
||||
$this->assertSame(1, Institution::withTrashed()->count());
|
||||
}
|
||||
}
|
||||
27
backend/tests/Unit/InstitutionLinkTypeTest.php
Normal file
27
backend/tests/Unit/InstitutionLinkTypeTest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class InstitutionLinkTypeTest extends TestCase
|
||||
{
|
||||
public function test_every_case_has_a_non_empty_label(): void
|
||||
{
|
||||
foreach (InstitutionLinkType::cases() as $case) {
|
||||
$this->assertNotSame('', $case->label());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_official_case_value_and_label(): void
|
||||
{
|
||||
$this->assertSame('official', InstitutionLinkType::Official->value);
|
||||
$this->assertSame('Official website', InstitutionLinkType::Official->label());
|
||||
}
|
||||
|
||||
public function test_it_is_a_string_backed_enum(): void
|
||||
{
|
||||
$this->assertSame(InstitutionLinkType::Social, InstitutionLinkType::from('social'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user