From 007ba9d5720642e2887f957e8fbb525963764705 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 15:08:05 -0300 Subject: [PATCH] feat(category): implement category configuration management with sorting and validation --- .../CategoryConfigurationController.php | 28 ++ .../Commerce/Catalog/Models/Category.php | 2 + .../UpdateCategoryConfigurationRequest.php | 69 +++++ .../CategoryConfigurationResource.php | 25 ++ .../Services/CategoryConfigurationService.php | 141 +++++++++ .../Commerce/Catalog/documentacion/README.md | 10 + .../Commerce/Catalog/routes/adminapp.php | 3 + .../Services/TenantBootstrapService.php | 5 +- .../Core/Tenant/Resources/TenantResource.php | 2 + ...200_add_sort_order_to_categorias_table.php | 34 +++ .../Catalog/CategoryConfigurationTest.php | 271 ++++++++++++++++++ 11 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Commerce/Catalog/Controllers/AdminApp/CategoryConfigurationController.php create mode 100644 app/Domains/Commerce/Catalog/Requests/AdminApp/UpdateCategoryConfigurationRequest.php create mode 100644 app/Domains/Commerce/Catalog/Resources/AdminApp/CategoryConfigurationResource.php create mode 100644 app/Domains/Commerce/Catalog/Services/CategoryConfigurationService.php create mode 100644 database/migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php create mode 100644 tests/Feature/Catalog/CategoryConfigurationTest.php diff --git a/app/Domains/Commerce/Catalog/Controllers/AdminApp/CategoryConfigurationController.php b/app/Domains/Commerce/Catalog/Controllers/AdminApp/CategoryConfigurationController.php new file mode 100644 index 00000000..9fae8a18 --- /dev/null +++ b/app/Domains/Commerce/Catalog/Controllers/AdminApp/CategoryConfigurationController.php @@ -0,0 +1,28 @@ +forTenant($request->user()->tenant()->firstOrFail()) + ); + } + + public function update(UpdateCategoryConfigurationRequest $request, CategoryConfigurationService $service): AnonymousResourceCollection + { + $tenant = $request->user()->tenant()->firstOrFail(); + $service->update($tenant, $request->validated()); + + return CategoryConfigurationResource::collection($service->forTenant($tenant)); + } +} diff --git a/app/Domains/Commerce/Catalog/Models/Category.php b/app/Domains/Commerce/Catalog/Models/Category.php index 77cccfbc..6be8b1ea 100644 --- a/app/Domains/Commerce/Catalog/Models/Category.php +++ b/app/Domains/Commerce/Catalog/Models/Category.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'is_enabled', 'banner_id', 'banner_enabled', + 'sort_order', ])] class Category extends Model { @@ -41,6 +42,7 @@ class Category extends Model 'is_enabled' => 'boolean', 'banner_id' => 'integer', 'banner_enabled' => 'boolean', + 'sort_order' => 'integer', ]; } diff --git a/app/Domains/Commerce/Catalog/Requests/AdminApp/UpdateCategoryConfigurationRequest.php b/app/Domains/Commerce/Catalog/Requests/AdminApp/UpdateCategoryConfigurationRequest.php new file mode 100644 index 00000000..ae7d12dc --- /dev/null +++ b/app/Domains/Commerce/Catalog/Requests/AdminApp/UpdateCategoryConfigurationRequest.php @@ -0,0 +1,69 @@ + ['present', 'array', 'max:500'], + 'deleted_ids' => ['present', 'array', 'max:1000'], + 'deleted_ids.*' => ['required', 'integer', 'min:1', 'distinct'], + ]; + } + + public function withValidator(ValidationValidator $validator): void + { + $validator->after(function (ValidationValidator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + $this->validateNodes($validator, $this->input('categories'), 'categories', 0); + }); + } + + private function validateNodes(ValidationValidator $validator, array $nodes, string $path, int $depth): void + { + if ($depth > 10) { + $validator->errors()->add($path, 'La jerarquía de categorías es demasiado profunda.'); + + return; + } + + foreach ($nodes as $index => $node) { + $key = "{$path}.{$index}"; + if (! is_array($node)) { + $validator->errors()->add($key, 'La categoría debe ser un objeto.'); + + continue; + } + $nested = Validator::make($node, [ + 'id' => ['present', 'nullable', 'integer', 'min:1'], + 'nombre' => ['required', 'string', 'max:255'], + 'is_enabled' => ['required', 'boolean'], + 'banner_enabled' => ['required', 'boolean'], + 'banner' => ['present', 'nullable', new ImageOrBase64Rule], + 'subcategories' => ['present', 'array', 'max:500'], + ]); + foreach ($nested->errors()->messages() as $field => $messages) { + foreach ($messages as $message) { + $validator->errors()->add("{$key}.{$field}", $message); + } + } + if (! $nested->fails()) { + $this->validateNodes($validator, $node['subcategories'], "{$key}.subcategories", $depth + 1); + } + } + } +} diff --git a/app/Domains/Commerce/Catalog/Resources/AdminApp/CategoryConfigurationResource.php b/app/Domains/Commerce/Catalog/Resources/AdminApp/CategoryConfigurationResource.php new file mode 100644 index 00000000..a623ada7 --- /dev/null +++ b/app/Domains/Commerce/Catalog/Resources/AdminApp/CategoryConfigurationResource.php @@ -0,0 +1,25 @@ + $this->id, + 'nombre' => $this->nombre, + 'is_enabled' => $this->is_enabled, + 'banner_enabled' => $this->banner_enabled, + 'banner_key' => $this->banner?->key, + 'banner_name' => $this->banner?->filename, + 'banner_url' => $this->banner?->getTemporaryUrl(1440), + 'subcategories' => self::collection($this->subCategories), + ]; + } +} diff --git a/app/Domains/Commerce/Catalog/Services/CategoryConfigurationService.php b/app/Domains/Commerce/Catalog/Services/CategoryConfigurationService.php new file mode 100644 index 00000000..df06fbfa --- /dev/null +++ b/app/Domains/Commerce/Catalog/Services/CategoryConfigurationService.php @@ -0,0 +1,141 @@ + */ + public function forTenant(Tenant $tenant): Collection + { + $all = $tenant->categories()->with('banner')->orderBy('sort_order')->orderBy('id')->get(); + $children = $all->groupBy('categoria_id'); + foreach ($all as $category) { + $category->setRelation('subCategories', $children->get($category->id, collect())); + } + + return $all->filter(fn (Category $category) => $category->categoria_id === null)->values(); + } + + /** @param array $data */ + public function update(Tenant $tenant, array $data): void + { + $created = []; + try { + DB::transaction(function () use ($tenant, $data, &$created): void { + // Serialize saves for this tenant, including creation of new categories. + Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail(); + $existing = $tenant->categories()->with('banner')->lockForUpdate()->get()->keyBy('id'); + $deleted = array_map('intval', $data['deleted_ids']); + $seen = []; + $allowedBannerKeys = $existing->pluck('banner.key')->filter()->all(); + $this->validateNodes($data['categories'], $existing, $deleted, $seen, $allowedBannerKeys); + + foreach ($deleted as $id) { + $category = $existing->get($id); + if ($category === null) { + $this->invalid('deleted_ids', 'Una categoría a eliminar no pertenece a esta tienda.'); + } + if ($category->catalogItems()->withTrashed()->exists() + || FeaturedGroup::query()->where('category_id', $id)->exists()) { + $this->invalid('deleted_ids', "No se puede eliminar «{$category->nombre}» porque tiene productos o grupos destacados asociados. Podés ocultarla."); + } + } + + $this->saveNodes($tenant, $data['categories'], null, $existing, $created); + $remaining = $tenant->categories()->whereNotIn('id', $deleted)->get(); + $parents = $remaining->pluck('categoria_id', 'id'); + foreach ($remaining as $category) { + $visited = [$category->id => true]; + $parentId = $category->categoria_id; + while ($parentId !== null) { + if (isset($visited[$parentId]) || ! $parents->has($parentId)) { + $this->invalid('categories', 'La jerarquía contiene un ciclo o una categoría padre eliminada.'); + } + $visited[$parentId] = true; + $parentId = $parents->get($parentId); + } + } + + // Detach only explicitly deleted nodes before removing parents and children together. + $tenant->categories()->whereIn('id', $deleted)->update(['categoria_id' => null]); + $tenant->categories()->whereIn('id', $deleted)->delete(); + }); + } catch (Throwable $error) { + foreach ($created as $attachment) { + Storage::disk('s3')->delete($attachment->path); + } + throw $error; + } + } + + private function validateNodes(array $nodes, Collection $existing, array $deleted, array &$seen, array $bannerKeys, string $path = 'categories'): void + { + foreach ($nodes as $index => $node) { + $field = "{$path}.{$index}"; + if ($node['id'] !== null) { + $id = (int) $node['id']; + if (! $existing->has($id) || isset($seen[$id]) || in_array($id, $deleted, true)) { + $this->invalid("{$field}.id", 'La categoría es ajena a la tienda, está repetida o fue marcada para eliminar.'); + } + $seen[$id] = true; + } + if (trim($node['nombre']) === '') { + $this->invalid("{$field}.nombre", 'Completá el nombre de la categoría.'); + } + if (is_string($node['banner']) && Str::isUuid($node['banner']) + && ! in_array($node['banner'], $bannerKeys, true)) { + $this->invalid("{$field}.banner", 'El banner no pertenece a las categorías de esta tienda.'); + } + $this->validateNodes($node['subcategories'], $existing, $deleted, $seen, $bannerKeys, "{$field}.subcategories"); + } + } + + private function saveNodes(Tenant $tenant, array $nodes, ?int $parentId, Collection $existing, array &$created): void + { + foreach (array_values($nodes) as $order => $node) { + $category = $node['id'] === null ? new Category : $existing->get((int) $node['id']); + $banner = null; + if ($node['banner'] !== null) { + if (is_string($node['banner']) && Str::isUuid($node['banner'])) { + $banner = Attachment::query()->where('key', $node['banner'])->firstOrFail(); + if ($banner->type !== AttachmentType::Image) { + $this->invalid('categories', 'El banner debe ser una imagen.'); + } + } else { + $banner = $this->attachments->store($node['banner'], "tenants/{$tenant->codigo}/categories"); + $created[] = $banner; + } + } + $category->fill([ + 'tenant_code' => $tenant->codigo, + 'categoria_id' => $parentId, + 'nombre' => trim($node['nombre']), + 'is_enabled' => $node['is_enabled'], + 'banner_id' => $banner?->id, + 'banner_enabled' => $node['banner_enabled'], + 'sort_order' => $order, + ])->save(); + $this->saveNodes($tenant, $node['subcategories'], $category->id, $existing, $created); + } + } + + private function invalid(string $field, string $message): never + { + throw ValidationException::withMessages([$field => $message]); + } +} diff --git a/app/Domains/Commerce/Catalog/documentacion/README.md b/app/Domains/Commerce/Catalog/documentacion/README.md index c4847726..d14e2c3e 100644 --- a/app/Domains/Commerce/Catalog/documentacion/README.md +++ b/app/Domains/Commerce/Catalog/documentacion/README.md @@ -31,6 +31,16 @@ Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, de Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados. +### Configuración de categorías de producto + +`GET /v1/adminapp/tenant/categories` devuelve un árbol ordenado de las categorías del tenant autenticado, incluidas las ocultas. Cada nodo contiene `id`, `nombre`, `is_enabled`, `banner_enabled`, `banner_key`, `banner_name`, `banner_url` y `subcategories`. La URL administrativa se devuelve incluso si el banner está deshabilitado. + +`PUT /v1/adminapp/tenant/categories` recibe `categories` y `deleted_ids`. Cada nodo enviado contiene `id` (null para altas), `nombre`, `is_enabled`, `banner_enabled`, `banner` y `subcategories`. `banner` es una clave UUID de un banner ya asociado a las categorías del tenant, una imagen base64/archivo para reemplazarlo o null para quitarlo. Deshabilitar un banner conserva el adjunto. Duplicar una categoría puede compartir su banner; los adjuntos previos no se eliminan al quitar o reemplazar esa referencia. + +La posición de cada nodo en su lista determina `sort_order`; este campo no se expone en las respuestas públicas ni administrativas. La migración inicial conserva el orden alfabético existente. El bootstrap del storefront ordena por `sort_order` e ID y no muestra categorías ocultas ni sus descendientes. + +El guardado es transaccional. Solo se eliminan los IDs explícitos, se rechazan IDs y banners ajenos, categorías repetidas y jerarquías inválidas. No se pueden eliminar categorías con productos (incluidos los archivados) o grupos destacados asociados. Los archivos recién subidos se limpian si falla la transacción. La respuesta devuelve el árbol persistido y los IDs asignados a las altas. + ## Dependencias y reglas Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. diff --git a/app/Domains/Commerce/Catalog/routes/adminapp.php b/app/Domains/Commerce/Catalog/routes/adminapp.php index ac844f02..1a009240 100644 --- a/app/Domains/Commerce/Catalog/routes/adminapp.php +++ b/app/Domains/Commerce/Catalog/routes/adminapp.php @@ -1,11 +1,14 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + Route::get('categories', [CategoryConfigurationController::class, 'index']); + Route::put('categories', [CategoryConfigurationController::class, 'update']); Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index']) ->name('adminapp.featured-groups.index'); Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store']) diff --git a/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php b/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php index a89e3c05..9a0f4fc6 100644 --- a/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php +++ b/app/Domains/Core/Bootstrap/Services/TenantBootstrapService.php @@ -41,8 +41,9 @@ class TenantBootstrapService 'roles', fn ($query) => $query->where('codigo', RoleCode::User->value) ), - $tenant->storefront_website_type_code === 'onticket_multi_event' - ? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'), + ...($tenant->storefront_website_type_code === 'onticket_multi_event' + ? ['eventCategories' => fn ($query) => $query->orderBy('nombre')] + : ['categories' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]), ] ); } diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index 2985fbc8..b5414a33 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -207,12 +207,14 @@ class TenantResource extends JsonResource 'nombre' => $category->nombre, 'subcategories' => $childrenByParent ->get($category->id, collect()) + ->filter(fn (Category $child) => $child->is_enabled) ->map($formatCategory) ->values(), ]; }; return $categories + ->filter(fn (Category $category) => $category->is_enabled) ->filter(fn (Category $category) => $category->categoria_id === null || ! $categoryIds->has($category->categoria_id)) ->map($formatCategory) diff --git a/database/migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php b/database/migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php new file mode 100644 index 00000000..d06dc538 --- /dev/null +++ b/database/migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php @@ -0,0 +1,34 @@ +unsignedInteger('sort_order')->default(0); + $table->index(['tenant_code', 'categoria_id', 'sort_order']); + }); + + // Preserve the alphabetical navigation order until an administrator reorders it. + $orders = []; + foreach (DB::table('categorias')->orderBy('nombre')->orderBy('id')->get() as $category) { + $group = json_encode([$category->tenant_code, $category->categoria_id]); + $order = $orders[$group] ?? 0; + DB::table('categorias')->where('id', $category->id)->update(['sort_order' => $order]); + $orders[$group] = $order + 1; + } + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table): void { + $table->dropIndex(['tenant_code', 'categoria_id', 'sort_order']); + $table->dropColumn('sort_order'); + }); + } +}; diff --git a/tests/Feature/Catalog/CategoryConfigurationTest.php b/tests/Feature/Catalog/CategoryConfigurationTest.php new file mode 100644 index 00000000..fd186f32 --- /dev/null +++ b/tests/Feature/Catalog/CategoryConfigurationTest.php @@ -0,0 +1,271 @@ +id(); + $table->string('codigo')->unique(); + $table->string('dominio')->nullable(); + $table->string('base_path')->default('/'); + $table->string('storefront_website_type_code')->default('shopit'); + }); + Schema::create('attachments', function (Blueprint $table): void { + $table->id(); + $table->string('key')->unique(); + $table->string('path'); + $table->string('filename'); + $table->string('type'); + $table->string('mime_type')->nullable(); + $table->string('extension')->nullable(); + $table->integer('size')->default(0); + $table->timestamps(); + }); + (require database_path('migrations/2026_06_24_000000_create_categorias_table.php'))->up(); + (require database_path('migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php'))->up(); + (require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->up(); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete(); + $table->softDeletes(); + }); + Schema::create('featured_groups', function (Blueprint $table): void { + $table->id(); + $table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete(); + }); + DB::table('tenants')->insert([['codigo' => 'shop'], ['codigo' => 'other']]); + DB::table('categorias')->insert([ + ['tenant_code' => 'shop', 'nombre' => 'Zeta'], + ['tenant_code' => 'shop', 'nombre' => 'Alfa'], + ]); + (require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->up(); + $this->tenant = Tenant::query()->where('codigo', 'shop')->firstOrFail(); + $user = new User; + $user->forceFill(['id' => 1, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::AdminApp->value]); + Sanctum::actingAs($user); + $this->mock(TemporaryUrlService::class, function ($mock): void { + $mock->shouldReceive('generate')->andReturnUsing(fn ($path) => [ + 'temporary_url' => 'https://example.com/'.$path, + 'temporary_url_expires_at' => now()->addDay()->toIso8601String(), + ]); + }); + } + + public function test_get_returns_sorted_tree_including_hidden_categories_and_disabled_banners(): void + { + $parent = Category::where('nombre', 'Alfa')->firstOrFail(); + $banner = $this->banner(); + $parent->update(['is_enabled' => false, 'banner_id' => $banner->id]); + $child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']); + Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']); + + $response = $this->getJson(self::URL)->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.nombre', 'Alfa') + ->assertJsonPath('data.0.is_enabled', false) + ->assertJsonPath('data.0.banner_enabled', false) + ->assertJsonPath('data.0.banner_key', $banner->key) + ->assertJsonPath('data.0.banner_url', 'https://example.com/banner.jpg') + ->assertJsonPath('data.0.subcategories.0.id', $child->id); + $this->assertArrayNotHasKey('sort_order', $response->json('data.0')); + } + + public function test_put_creates_edits_reorders_and_removes_only_explicitly_deleted_nodes(): void + { + $alfa = Category::where('nombre', 'Alfa')->firstOrFail(); + $zeta = Category::where('nombre', 'Zeta')->firstOrFail(); + $child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $alfa->id, 'nombre' => 'Eliminar']); + $this->putJson(self::URL, [ + 'categories' => [ + $this->node($zeta, ['nombre' => 'Primera', 'is_enabled' => false]), + $this->node($alfa, ['subcategories' => [$this->node(null, ['nombre' => 'Nueva hija'])]]), + $this->node(null, ['nombre' => 'Nueva raíz']), + ], + 'deleted_ids' => [$child->id], + ])->assertOk()->assertJsonPath('data.0.nombre', 'Primera') + ->assertJsonPath('data.1.subcategories.0.nombre', 'Nueva hija') + ->assertJsonPath('data.2.nombre', 'Nueva raíz'); + $this->assertSame(0, $zeta->fresh()->sort_order); + $this->assertSame(1, $alfa->fresh()->sort_order); + $this->assertDatabaseMissing('categorias', ['id' => $child->id]); + + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertOk(); + $this->assertDatabaseCount('categorias', 4); + } + + public function test_it_preserves_existing_banners_and_supports_replacement_and_removal(): void + { + $category = Category::where('nombre', 'Alfa')->firstOrFail(); + $banner = $this->banner(); + $category->update(['banner_id' => $banner->id]); + $this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $banner->key, 'banner_enabled' => true])], 'deleted_ids' => []]) + ->assertOk()->assertJsonPath('data.0.banner_key', $banner->key); + + $replacement = $this->banner('replacement.jpg'); + $this->mock(AttachmentService::class, function ($mock) use ($replacement): void { + $mock->shouldReceive('store')->once()->withArgs(fn ($image, $path) => str_starts_with($image, 'data:image/png;base64,') && $path === 'tenants/shop/categories')->andReturn($replacement); + }); + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII='; + $this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $image])], 'deleted_ids' => []]) + ->assertOk()->assertJsonPath('data.0.banner_key', $replacement->key); + $this->putJson(self::URL, ['categories' => [$this->node($category)], 'deleted_ids' => []]) + ->assertOk()->assertJsonPath('data.0.banner_key', null); + $this->assertDatabaseHas('attachments', ['id' => $banner->id]); + } + + public function test_it_rejects_foreign_ids_duplicate_ids_and_foreign_banners_atomically(): void + { + $local = Category::where('nombre', 'Alfa')->firstOrFail(); + $foreign = Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']); + foreach ([ + [$this->node($local, ['nombre' => 'Cambio']), $this->node($foreign)], + [$this->node($local), $this->node($local)], + [$this->node($local, ['banner' => $this->banner()->key])], + ] as $nodes) { + $this->putJson(self::URL, ['categories' => $nodes, 'deleted_ids' => []])->assertUnprocessable(); + $this->assertSame('Alfa', $local->fresh()->nombre); + } + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$foreign->id]])->assertUnprocessable(); + } + + public function test_it_rejects_deleting_categories_with_products_or_featured_groups(): void + { + $category = Category::where('nombre', 'Alfa')->firstOrFail(); + foreach (['catalog_items', 'featured_groups'] as $table) { + DB::table($table)->insert(['category_id' => $category->id]); + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]]) + ->assertUnprocessable()->assertJsonValidationErrors('deleted_ids'); + $this->assertDatabaseHas('categorias', ['id' => $category->id]); + DB::table($table)->delete(); + } + DB::table('catalog_items')->insert(['category_id' => $category->id, 'deleted_at' => now()]); + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]]) + ->assertUnprocessable()->assertJsonValidationErrors('deleted_ids'); + } + + public function test_it_stores_new_banner_uploads_and_cleans_up_files_when_a_save_rolls_back(): void + { + Storage::fake('s3'); + $image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII='; + $parent = Category::where('nombre', 'Alfa')->firstOrFail(); + $this->putJson(self::URL, ['categories' => [$this->node($parent, ['banner' => $image])], 'deleted_ids' => []])->assertOk(); + $banner = $parent->fresh()->banner; + Storage::disk('s3')->assertExists($banner->path); + Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']); + $this->putJson(self::URL, ['categories' => [$this->node(null, ['banner' => $image])], 'deleted_ids' => [$parent->id]])->assertUnprocessable(); + $this->assertDatabaseCount('attachments', 1); + $this->assertCount(1, Storage::disk('s3')->allFiles()); + $this->assertDatabaseHas('categorias', ['id' => $parent->id]); + } + + public function test_it_rejects_orphaning_children_and_cycles_but_can_delete_a_whole_tree(): void + { + $parent = Category::where('nombre', 'Alfa')->firstOrFail(); + $child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']); + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id]])->assertUnprocessable(); + $this->putJson(self::URL, ['categories' => [$this->node($child, ['subcategories' => [$this->node($parent)]])], 'deleted_ids' => []])->assertOk(); + $this->assertSame($child->id, $parent->fresh()->categoria_id); + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id, $child->id]])->assertOk(); + $this->assertDatabaseMissing('categorias', ['id' => $parent->id]); + } + + public function test_it_validates_nested_names_images_and_required_fields(): void + { + foreach ([['nombre' => ' '], ['banner' => 'not-an-image'], ['is_enabled' => 'invalid']] as $patch) { + $this->putJson(self::URL, [ + 'categories' => [$this->node(null, ['subcategories' => [$this->node(null, $patch)]])], + 'deleted_ids' => [], + ])->assertUnprocessable(); + } + $this->putJson(self::URL, ['categories' => []])->assertUnprocessable()->assertJsonValidationErrors('deleted_ids'); + $this->assertDatabaseCount('categorias', 2); + } + + public function test_non_adminapp_users_cannot_change_categories(): void + { + $user = new User; + $user->forceFill(['id' => 2, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::User->value]); + Sanctum::actingAs($user); + $this->getJson(self::URL)->assertForbidden(); + $this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertForbidden(); + } + + public function test_sort_order_migration_can_be_rolled_back(): void + { + (require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->down(); + $this->assertFalse(Schema::hasColumn('categorias', 'sort_order')); + $this->assertDatabaseCount('categorias', 2); + } + + public function test_public_bootstrap_uses_saved_order_without_exposing_it_and_hides_disabled_branches(): void + { + DB::table('tenants')->where('codigo', 'shop')->update(['dominio' => 'shop.test']); + $alfa = Category::where('nombre', 'Alfa')->firstOrFail(); + $zeta = Category::where('nombre', 'Zeta')->firstOrFail(); + $alfa->update(['is_enabled' => false]); + $first = Category::create(['tenant_code' => 'shop', 'nombre' => 'Z hija', 'categoria_id' => $zeta->id, 'sort_order' => 0]); + Category::create(['tenant_code' => 'shop', 'nombre' => 'A hija', 'categoria_id' => $zeta->id, 'sort_order' => 1]); + Category::create(['tenant_code' => 'shop', 'nombre' => 'Oculta', 'categoria_id' => $zeta->id, 'is_enabled' => false]); + Category::create(['tenant_code' => 'shop', 'nombre' => 'Hija de padre oculto', 'categoria_id' => $alfa->id]); + $this->mock(TenantInformationService::class, function ($mock): void { + $mock->shouldReceive('load')->once()->andReturnUsing(function ($tenant, $relations) { + return $tenant->load(['categories' => $relations['categories']]); + }); + }); + $loaded = app(TenantBootstrapService::class)->get('shop.test'); + $this->assertSame($alfa->id, $loaded->categories->first()->id); + $resourceTenant = new Tenant(['codigo' => 'shop', 'storefront_website_type_code' => 'shopit']); + foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) { + $resourceTenant->setRelation($relation, null); + } + $resourceTenant->setRelation('categories', $loaded->categories); + $data = TenantResource::make($resourceTenant)->resolve(request()); + $this->assertCount(1, $data['categories']); + $this->assertSame('Zeta', $data['categories'][0]['nombre']); + $this->assertSame(['Z hija', 'A hija'], $data['categories'][0]['subcategories']->pluck('nombre')->all()); + $this->assertSame($first->id, $data['categories'][0]['subcategories'][0]['id']); + $this->assertArrayNotHasKey('sort_order', $data['categories'][0]); + } + + private function node(?Category $category, array $patch = []): array + { + return array_replace([ + 'id' => $category?->id, + 'nombre' => $category?->nombre ?? 'Nueva', + 'is_enabled' => true, + 'banner_enabled' => false, + 'banner' => null, + 'subcategories' => [], + ], $patch); + } + + private function banner(string $path = 'banner.jpg'): Attachment + { + return Attachment::create(['path' => $path, 'filename' => $path, 'type' => 'image']); + } +}