feat(category): implement category configuration management with sorting and validation
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpdateCategoryConfigurationRequest;
|
||||
use App\Domains\Commerce\Catalog\Resources\AdminApp\CategoryConfigurationResource;
|
||||
use App\Domains\Commerce\Catalog\Services\CategoryConfigurationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryConfigurationController extends Controller
|
||||
{
|
||||
public function index(Request $request, CategoryConfigurationService $service): AnonymousResourceCollection
|
||||
{
|
||||
return CategoryConfigurationResource::collection(
|
||||
$service->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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
|
||||
|
||||
use App\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Validator as ValidationValidator;
|
||||
|
||||
class UpdateCategoryConfigurationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categories' => ['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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Category */
|
||||
class CategoryConfigurationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Shared\Attachable\Enums\AttachmentType;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class CategoryConfigurationService
|
||||
{
|
||||
public function __construct(private readonly AttachmentService $attachments) {}
|
||||
|
||||
/** @return Collection<int, Category> */
|
||||
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<string, mixed> $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]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Commerce\Catalog\Controllers\AdminApp\CategoryConfigurationController;
|
||||
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->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'])
|
||||
|
||||
@@ -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')]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('categorias', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
271
tests/Feature/Catalog/CategoryConfigurationTest.php
Normal file
271
tests/Feature/Catalog/CategoryConfigurationTest.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Catalog;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Core\Bootstrap\Services\TenantBootstrapService;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Core\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CategoryConfigurationTest extends TestCase
|
||||
{
|
||||
private const URL = '/api/v1/adminapp/tenant/categories';
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Schema::create('tenants', function (Blueprint $table): void {
|
||||
$table->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user