diff --git a/backend/app/Console/Commands/V1ImportCommand.php b/backend/app/Console/Commands/V1ImportCommand.php new file mode 100644 index 0000000..54971a4 --- /dev/null +++ b/backend/app/Console/Commands/V1ImportCommand.php @@ -0,0 +1,91 @@ +> + */ + 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; + } +} diff --git a/backend/app/Enums/InstitutionLinkType.php b/backend/app/Enums/InstitutionLinkType.php new file mode 100644 index 0000000..1fe3fd5 --- /dev/null +++ b/backend/app/Enums/InstitutionLinkType.php @@ -0,0 +1,33 @@ + 'Official website', + self::Ticketing => 'Online ticketing', + self::Social => 'Social media', + self::Catalogue => 'Online catalogue', + self::Other => 'Other resource', + }; + } +} diff --git a/backend/app/Etl/Contracts/Importer.php b/backend/app/Etl/Contracts/Importer.php new file mode 100644 index 0000000..f26efe4 --- /dev/null +++ b/backend/app/Etl/Contracts/Importer.php @@ -0,0 +1,31 @@ + */ + public array $warnings = []; + + public function warn(string $message): void + { + $this->warnings[] = $message; + } + + public function total(): int + { + return $this->created + $this->updated + $this->skipped; + } +} diff --git a/backend/app/Etl/Importers/InstitutionImporter.php b/backend/app/Etl/Importers/InstitutionImporter.php new file mode 100644 index 0000000..c7b8275 --- /dev/null +++ b/backend/app/Etl/Importers/InstitutionImporter.php @@ -0,0 +1,160 @@ + $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 $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 + */ + 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, + ], + ); + } +} diff --git a/backend/app/Http/Controllers/InstitutionCategoryController.php b/backend/app/Http/Controllers/InstitutionCategoryController.php new file mode 100644 index 0000000..3423b2d --- /dev/null +++ b/backend/app/Http/Controllers/InstitutionCategoryController.php @@ -0,0 +1,80 @@ +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(), + ]); + } +} diff --git a/backend/app/Http/Controllers/InstitutionController.php b/backend/app/Http/Controllers/InstitutionController.php new file mode 100644 index 0000000..74054ee --- /dev/null +++ b/backend/app/Http/Controllers/InstitutionController.php @@ -0,0 +1,172 @@ +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 $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 $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); + }); + } +} diff --git a/backend/app/Http/Controllers/InstitutionLinkController.php b/backend/app/Http/Controllers/InstitutionLinkController.php new file mode 100644 index 0000000..a4c531a --- /dev/null +++ b/backend/app/Http/Controllers/InstitutionLinkController.php @@ -0,0 +1,69 @@ +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(); + } +} diff --git a/backend/app/Http/Requests/IndexInstitutionRequest.php b/backend/app/Http/Requests/IndexInstitutionRequest.php new file mode 100644 index 0000000..17485b2 --- /dev/null +++ b/backend/app/Http/Requests/IndexInstitutionRequest.php @@ -0,0 +1,31 @@ + + */ + 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'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreInstitutionCategoryRequest.php b/backend/app/Http/Requests/StoreInstitutionCategoryRequest.php new file mode 100644 index 0000000..23d06ce --- /dev/null +++ b/backend/app/Http/Requests/StoreInstitutionCategoryRequest.php @@ -0,0 +1,27 @@ +|string> + */ + public function rules(): array + { + return [ + 'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreInstitutionLinkRequest.php b/backend/app/Http/Requests/StoreInstitutionLinkRequest.php new file mode 100644 index 0000000..d8797c7 --- /dev/null +++ b/backend/app/Http/Requests/StoreInstitutionLinkRequest.php @@ -0,0 +1,37 @@ +mergeIfMissing(['sort_order' => 0]); + } + + /** + * @return array|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'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreInstitutionRequest.php b/backend/app/Http/Requests/StoreInstitutionRequest.php new file mode 100644 index 0000000..379114d --- /dev/null +++ b/backend/app/Http/Requests/StoreInstitutionRequest.php @@ -0,0 +1,48 @@ +mergeIfMissing([ + 'color' => '#c5cae9', + 'is_storage_place' => true, + ]); + } + + /** + * @return array|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'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateInstitutionCategoryRequest.php b/backend/app/Http/Requests/UpdateInstitutionCategoryRequest.php new file mode 100644 index 0000000..e07a72a --- /dev/null +++ b/backend/app/Http/Requests/UpdateInstitutionCategoryRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + $id = $this->route('institution_category')->id; + + return [ + 'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateInstitutionLinkRequest.php b/backend/app/Http/Requests/UpdateInstitutionLinkRequest.php new file mode 100644 index 0000000..be0a8f1 --- /dev/null +++ b/backend/app/Http/Requests/UpdateInstitutionLinkRequest.php @@ -0,0 +1,37 @@ +mergeIfMissing(['sort_order' => 0]); + } + + /** + * @return array|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'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateInstitutionRequest.php b/backend/app/Http/Requests/UpdateInstitutionRequest.php new file mode 100644 index 0000000..a9b4788 --- /dev/null +++ b/backend/app/Http/Requests/UpdateInstitutionRequest.php @@ -0,0 +1,40 @@ +|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'], + ]; + } +} diff --git a/backend/app/Models/Institution.php b/backend/app/Models/Institution.php new file mode 100644 index 0000000..6cf5768 --- /dev/null +++ b/backend/app/Models/Institution.php @@ -0,0 +1,90 @@ +uuid)) { + $institution->uuid = (string) Str::orderedUuid(); + } + }); + } + + /** + * @return array + */ + 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 + */ + public function category(): BelongsTo + { + return $this->belongsTo(InstitutionCategory::class, 'category_id'); + } + + /** + * Risorse web collegate (sito ufficiale, ticketing, social, ...). + * + * @return HasMany + */ + public function links(): HasMany + { + return $this->hasMany(InstitutionLink::class); + } +} diff --git a/backend/app/Models/InstitutionLink.php b/backend/app/Models/InstitutionLink.php new file mode 100644 index 0000000..0337f15 --- /dev/null +++ b/backend/app/Models/InstitutionLink.php @@ -0,0 +1,46 @@ + + */ + protected function casts(): array + { + return [ + 'institution_id' => 'integer', + 'type' => InstitutionLinkType::class, + 'sort_order' => 'integer', + ]; + } + + /** + * Istituzione a cui appartiene il link. + * + * @return BelongsTo + */ + public function institution(): BelongsTo + { + return $this->belongsTo(Institution::class); + } +} diff --git a/backend/app/Models/Lists/InstitutionCategory.php b/backend/app/Models/Lists/InstitutionCategory.php new file mode 100644 index 0000000..45d2e82 --- /dev/null +++ b/backend/app/Models/Lists/InstitutionCategory.php @@ -0,0 +1,56 @@ + + */ + 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(); + } +} diff --git a/backend/config/database.php b/backend/config/database.php index abbb88e..5950bca 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -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'), diff --git a/backend/database/factories/InstitutionFactory.php b/backend/database/factories/InstitutionFactory.php new file mode 100644 index 0000000..8b59b78 --- /dev/null +++ b/backend/database/factories/InstitutionFactory.php @@ -0,0 +1,34 @@ + + */ +class InstitutionFactory extends Factory +{ + protected $model = Institution::class; + + /** + * @return array + */ + 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, + ]; + } +} diff --git a/backend/database/factories/InstitutionLinkFactory.php b/backend/database/factories/InstitutionLinkFactory.php new file mode 100644 index 0000000..ed0a6f7 --- /dev/null +++ b/backend/database/factories/InstitutionLinkFactory.php @@ -0,0 +1,30 @@ + + */ +class InstitutionLinkFactory extends Factory +{ + protected $model = InstitutionLink::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'institution_id' => Institution::factory(), + 'url' => fake()->url(), + 'type' => InstitutionLinkType::Official, + 'label' => null, + 'sort_order' => 0, + ]; + } +} diff --git a/backend/database/factories/Lists/InstitutionCategoryFactory.php b/backend/database/factories/Lists/InstitutionCategoryFactory.php new file mode 100644 index 0000000..86107af --- /dev/null +++ b/backend/database/factories/Lists/InstitutionCategoryFactory.php @@ -0,0 +1,24 @@ + + */ +class InstitutionCategoryFactory extends Factory +{ + protected $model = InstitutionCategory::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'value' => fake()->unique()->word(), + ]; + } +} diff --git a/backend/database/migrations/2026_06_22_150000_create_institution_categories_table.php b/backend/database/migrations/2026_06_22_150000_create_institution_categories_table.php new file mode 100644 index 0000000..5ffdd69 --- /dev/null +++ b/backend/database/migrations/2026_06_22_150000_create_institution_categories_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('value', 25)->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('institution_categories'); + } +}; diff --git a/backend/database/migrations/2026_06_22_150001_create_institutions_table.php b/backend/database/migrations/2026_06_22_150001_create_institutions_table.php new file mode 100644 index 0000000..0c3b0ce --- /dev/null +++ b/backend/database/migrations/2026_06_22_150001_create_institutions_table.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/backend/database/migrations/2026_06_22_150002_create_institution_links_table.php b/backend/database/migrations/2026_06_22_150002_create_institution_links_table.php new file mode 100644 index 0000000..0515dd2 --- /dev/null +++ b/backend/database/migrations/2026_06_22_150002_create_institution_links_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index ba68f46..0a3555f 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder $this->call([ UserRoleSeeder::class, SystemUserSeeder::class, + InstitutionCategorySeeder::class, ]); } } diff --git a/backend/database/seeders/InstitutionCategorySeeder.php b/backend/database/seeders/InstitutionCategorySeeder.php new file mode 100644 index 0000000..358f064 --- /dev/null +++ b/backend/database/seeders/InstitutionCategorySeeder.php @@ -0,0 +1,33 @@ + InstitutionCategory::LIBRARY, + 3 => InstitutionCategory::MUSEUM, + 4 => InstitutionCategory::PUBLIC_ADMINISTRATION, + 6 => InstitutionCategory::RESEARCH_INSTITUTE, + ]; + + foreach ($categories as $id => $value) { + InstitutionCategory::updateOrCreate( + ['id' => $id], + ['value' => $value], + ); + } + } +} diff --git a/backend/routes/api/institution-categories.php b/backend/routes/api/institution-categories.php new file mode 100644 index 0000000..875f10e --- /dev/null +++ b/backend/routes/api/institution-categories.php @@ -0,0 +1,17 @@ +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']); +}); diff --git a/backend/routes/api/institutions.php b/backend/routes/api/institutions.php new file mode 100644 index 0000000..79830c3 --- /dev/null +++ b/backend/routes/api/institutions.php @@ -0,0 +1,29 @@ +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(); +}); diff --git a/backend/tests/Concerns/CreatesLegacyDatabase.php b/backend/tests/Concerns/CreatesLegacyDatabase.php new file mode 100644 index 0000000..3d86949 --- /dev/null +++ b/backend/tests/Concerns/CreatesLegacyDatabase.php @@ -0,0 +1,74 @@ +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 $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']; + } +} diff --git a/backend/tests/Feature/Etl/InstitutionImporterTest.php b/backend/tests/Feature/Etl/InstitutionImporterTest.php new file mode 100644 index 0000000..0a3ba28 --- /dev/null +++ b/backend/tests/Feature/Etl/InstitutionImporterTest.php @@ -0,0 +1,168 @@ +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); + } +} diff --git a/backend/tests/Feature/Etl/V1ImportCommandTest.php b/backend/tests/Feature/Etl/V1ImportCommandTest.php new file mode 100644 index 0000000..c970780 --- /dev/null +++ b/backend/tests/Feature/Etl/V1ImportCommandTest.php @@ -0,0 +1,94 @@ +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(); + } +} diff --git a/backend/tests/Feature/InstitutionCategoryControllerTest.php b/backend/tests/Feature/InstitutionCategoryControllerTest.php new file mode 100644 index 0000000..1bf8e08 --- /dev/null +++ b/backend/tests/Feature/InstitutionCategoryControllerTest.php @@ -0,0 +1,110 @@ +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(); + } +} diff --git a/backend/tests/Feature/InstitutionCategorySeederTest.php b/backend/tests/Feature/InstitutionCategorySeederTest.php new file mode 100644 index 0000000..70b38bb --- /dev/null +++ b/backend/tests/Feature/InstitutionCategorySeederTest.php @@ -0,0 +1,40 @@ +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()); + } +} diff --git a/backend/tests/Feature/InstitutionControllerTest.php b/backend/tests/Feature/InstitutionControllerTest.php new file mode 100644 index 0000000..ab56d38 --- /dev/null +++ b/backend/tests/Feature/InstitutionControllerTest.php @@ -0,0 +1,231 @@ + $overrides + * @return array + */ + 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(); + } +} diff --git a/backend/tests/Feature/InstitutionLinkControllerTest.php b/backend/tests/Feature/InstitutionLinkControllerTest.php new file mode 100644 index 0000000..1fde23c --- /dev/null +++ b/backend/tests/Feature/InstitutionLinkControllerTest.php @@ -0,0 +1,149 @@ + $overrides + * @return array + */ + 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(); + } +} diff --git a/backend/tests/Feature/Models/InstitutionLinkTest.php b/backend/tests/Feature/Models/InstitutionLinkTest.php new file mode 100644 index 0000000..24d1249 --- /dev/null +++ b/backend/tests/Feature/Models/InstitutionLinkTest.php @@ -0,0 +1,30 @@ +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)); + } +} diff --git a/backend/tests/Feature/Models/InstitutionTest.php b/backend/tests/Feature/Models/InstitutionTest.php new file mode 100644 index 0000000..1c0fdd9 --- /dev/null +++ b/backend/tests/Feature/Models/InstitutionTest.php @@ -0,0 +1,99 @@ +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()); + } +} diff --git a/backend/tests/Unit/InstitutionLinkTypeTest.php b/backend/tests/Unit/InstitutionLinkTypeTest.php new file mode 100644 index 0000000..20d8686 --- /dev/null +++ b/backend/tests/Unit/InstitutionLinkTypeTest.php @@ -0,0 +1,27 @@ +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')); + } +}