feat(category): implement category configuration management with sorting and validation

This commit is contained in:
2026-10-01 15:08:05 -03:00
parent 95442d4e0b
commit 57ea0024dd
11 changed files with 588 additions and 2 deletions

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