refactor(catalog): Complete catalog refactor to simplify its data model and its querying.

source commits: refactor/catalog
This commit is contained in:
2026-07-21 09:23:19 -03:00
parent d3182fbd13
commit 29a2ca19a3
116 changed files with 5141 additions and 5217 deletions

View File

@@ -4,107 +4,70 @@ namespace Tests\Feature\Cart;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\ProductVariantDefinition;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CartControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_an_empty_guest_cart_when_cart_does_not_exist(): void
public function test_cart_items_have_explicit_catalog_and_optional_variant_keys(): void
{
$this->createTenant('acme', 'Acme', 'acme.com');
$this->getJson('/api/tenants/acme/cart')
->assertOk()
->assertJson([
'data' => [
'id' => null,
'tenant_codigo' => 'acme',
'status' => 'active',
'items' => [],
'subtotal' => '0.00',
],
]);
$this->assertTrue(Schema::hasColumns('carrito_items', [
'catalog_item_id',
'variant_id',
]));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
}
public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void
public function test_it_adds_a_catalog_item_without_a_variant(): void
{
$variant = $this->createVariantForTenant('acme', 10, '49.90');
$attribute = Attribute::query()->create([
'tenant_codigo' => 'acme',
'codigo' => 'color',
'nombre' => 'Color',
'type' => 'string',
]);
$productAttribute = ProductAttribute::query()->create([
'product_id' => $variant->producto_id,
'attribute_id' => $attribute->id,
]);
ProductVariantDefinition::query()->create([
'producto_variante_id' => $variant->id,
'products_attribute_id' => $productAttribute->id,
'value' => 'Red',
]);
$tenant = $this->createTenant('acme');
$item = $this->createDirectItem($tenant, 10, '49.90');
$response = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $item->id,
'cantidad' => 2,
]);
$response
->assertOk()
->assertCookie('guest_token')
->assertJsonPath('data.tenant_codigo', 'acme')
->assertJsonPath('data.items.0.catalog_item_id', $item->id)
->assertJsonPath('data.items.0.variant_id', null)
->assertJsonPath('data.items.0.cantidad', 2)
->assertJsonPath('data.items.0.precio_unitario', '49.90')
->assertJsonPath('data.items.0.buyable_type', 'variant')
->assertJsonPath('data.items.0.buyable_id', $variant->id)
->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)')
->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.items.0.product.nombre', 'Item acme')
->assertJsonPath('data.subtotal', '99.80');
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'acme',
'guest_token' => $response->getCookie('guest_token', false)?->getValue(),
'status' => 'active',
]);
$this->assertDatabaseHas('carrito_items', [
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'catalog_item_id' => $item->id,
'variant_id' => null,
'cantidad' => 2,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 10,
'stock_reservado' => 2,
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'reserved_stock' => 2,
]);
}
public function test_it_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
{
$variant = $this->createVariantForTenant('acme', 12, '25.00');
$tenant = $this->createTenant('acme');
[$item, $variant] = $this->createVariantItem($tenant, 12, '25.00');
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'cantidad' => 2,
]);
])->assertOk();
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
@@ -116,279 +79,38 @@ class CartControllerTest extends TestCase
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'cantidad' => 3,
])
]),
);
$response
->assertOk()
->assertJsonPath('data.items.0.catalog_item_id', $item->id)
->assertJsonPath('data.items.0.variant_id', $variant->id)
->assertJsonPath('data.items.0.cantidad', 5)
->assertJsonPath('data.subtotal', '125.00');
$this->assertDatabaseCount('carritos', 1);
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('carrito_items', [
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 5,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 12,
'stock_reservado' => 5,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 5,
]);
}
public function test_it_updates_item_quantity_and_adjusts_stock(): void
public function test_it_updates_and_removes_an_item_using_its_selected_inventory(): void
{
$variant = $this->createVariantForTenant('acme', 10, '15.00');
$tenant = $this->createTenant('acme');
[$item, $variant] = $this->createVariantItem($tenant, 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'cantidad' => 2,
]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$response = $this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'cantidad' => 5,
])
);
$response
->assertOk()
->assertJsonPath('data.items.0.cantidad', 5)
->assertJsonPath('data.subtotal', '75.00');
$this->assertDatabaseHas('carrito_items', [
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'cantidad' => 5,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 10,
'stock_reservado' => 5,
]);
}
public function test_it_removes_an_item_and_restores_stock(): void
{
$variant = $this->createVariantForTenant('acme', 10, '15.00');
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 4,
]);
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
$cartItemId = $createResponse->json('data.items.0.id');
$response = $this->call(
'DELETE',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json']
);
$response
->assertOk()
->assertJsonPath('data.items', [])
->assertJsonPath('data.subtotal', '0.00');
$this->assertDatabaseCount('carrito_items', 0);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 10,
'stock_reservado' => 0,
]);
}
public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void
{
$user = User::factory()->create();
$acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00');
$acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie');
$globexVariant = $this->createVariantForTenant('globex', 6, '30.00');
$this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $acmeVariantA->id,
'cantidad' => 1,
])
->assertOk();
$this->actingAs($user)
->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $acmeVariantB->id,
'cantidad' => 2,
])
->assertOk()
->assertJsonPath('data.subtotal', '50.00');
$this->actingAs($user)
->postJson('/api/tenants/globex/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $globexVariant->id,
'cantidad' => 1,
])
->assertOk();
$this->assertDatabaseCount('carritos', 2);
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'acme',
'user_id' => $user->id,
]);
$this->assertDatabaseHas('carritos', [
'tenant_codigo' => 'globex',
'user_id' => $user->id,
]);
}
public function test_it_rejects_variants_from_another_tenant(): void
{
$this->createVariantForTenant('acme', 10, '10.00');
$otherVariant = $this->createVariantForTenant('globex', 10, '20.00');
$this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $otherVariant->id,
'cantidad' => 1,
])->assertNotFound();
}
public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void
{
$variant = $this->createVariantForTenant('acme', 10, '10.00');
$this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [
'cantidad' => 2,
])->assertNotFound();
$this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}")
->assertNotFound();
}
public function test_it_validates_quantity_and_stock_constraints(): void
{
$variant = $this->createVariantForTenant('acme', 2, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 0,
])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 2,
]);
$guestToken = $response->getCookie('guest_token', false)?->getValue();
$cartItemId = $response->json('data.items.0.id');
$response1 = $this->call(
'POST',
'/api/tenants/acme/cart/items',
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1,
])
);
$response1
->assertUnprocessable()
->assertJsonValidationErrors(['cantidad']);
$response2 = $this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'cantidad' => 3,
])
);
$response2
->assertUnprocessable()
->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']);
}
public function test_it_only_accepts_buyable_identity_when_adding_an_item(): void
{
$variant = $this->createVariantForTenant('acme', 10, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [
'product_variant_id' => $variant->id,
'cantidad' => 1,
])->assertUnprocessable()
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 1,
])->assertOk();
$cartItemId = $createResponse->json('data.items.0.id');
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 2,
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
])->assertUnprocessable()
->assertJsonValidationErrors(['buyable_type', 'buyable_id']);
}
public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void
{
$variant = $this->createVariantForTenant(
'acme',
0,
'10.00',
'unlimited',
InventoryPolicy::Unlimited,
);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'cantidad' => 100,
])->assertOk();
$guestToken = $response->getCookie('guest_token', false)?->getValue();
$cartItemId = $response->json('data.items.0.id');
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 100,
]);
$this->call(
'PATCH',
"/api/tenants/acme/cart/items/{$cartItemId}",
@@ -396,13 +118,14 @@ class CartControllerTest extends TestCase
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode(['cantidad' => 150]),
)->assertOk();
json_encode(['cantidad' => 5]),
)
->assertOk()
->assertJsonPath('data.items.0.cantidad', 5);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 150,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 5,
]);
$this->call(
@@ -412,84 +135,143 @@ class CartControllerTest extends TestCase
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json'],
)->assertOk();
)
->assertOk()
->assertJsonPath('data.items', []);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 0,
'stock_reservado' => 0,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 0,
]);
}
protected function createVariantForTenant(
string $tenantCode,
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
{
$tenant = $this->createTenant('acme');
[$item] = $this->createVariantItem($tenant, 10, '10.00');
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])->assertUnprocessable()->assertJsonValidationErrors('variant_id');
}
public function test_it_rejects_a_variant_from_another_item_or_tenant(): void
{
$acme = $this->createTenant('acme');
$globex = $this->createTenant('globex');
[$acmeItem] = $this->createVariantItem($acme, 10, '10.00');
[, $globexVariant] = $this->createVariantItem($globex, 10, '20.00');
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $acmeItem->id,
'variant_id' => $globexVariant->id,
'cantidad' => 1,
])->assertUnprocessable()->assertJsonValidationErrors('variant_id');
}
public function test_unlimited_inventory_can_be_reserved_and_released(): void
{
$tenant = $this->createTenant('acme');
$item = $this->createDirectItem(
$tenant,
0,
'10.00',
InventoryPolicy::Unlimited,
);
$response = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 100,
])->assertOk();
$guestToken = $response->getCookie('guest_token', false)?->getValue();
$cartItemId = $response->json('data.items.0.id');
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'real_stock' => 0,
'reserved_stock' => 100,
]);
$this->call(
'DELETE',
"/api/tenants/acme/cart/items/{$cartItemId}",
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json'],
)
->assertOk();
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'reserved_stock' => 0,
]);
}
private function createDirectItem(
Tenant $tenant,
int $stock,
string $price,
string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant {
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant) {
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
}
InventoryPolicy $policy = InventoryPolicy::Tracked,
): CatalogItem {
$inventory = Inventory::query()->create(['real_stock' => $stock]);
$category = Category::query()->create([
'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}",
]);
$product = Product::query()->create([
'tenant_codigo' => $tenantCode,
'categoria_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
'descripcion' => 'Test product',
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory->id,
'slug' => 'item-'.$tenant->codigo.'-'.CatalogItem::query()->count(),
'nombre' => 'Item '.$tenant->codigo,
'precio' => $price,
'inventory_policy' => $policy,
]);
return ProductVariant::query()->create([
'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock,
'descripcion' => 'Test variant',
'precio' => $price,
])->load('product');
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
/** @return array{CatalogItem, Variant} */
private function createVariantItem(Tenant $tenant, int $stock, string $price): array
{
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$item = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => null,
'slug' => 'variant-item-'.$tenant->codigo.'-'.CatalogItem::query()->count(),
'nombre' => 'Variant item '.$tenant->codigo,
'precio' => $price,
'inventory_policy' => InventoryPolicy::Tracked,
]);
$inventory = Inventory::query()->create(['real_stock' => $stock]);
$variant = $item->variants()->create(['inventory_id' => $inventory->id]);
$headerAttachment = Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png',
return [$item, $variant];
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -1,131 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AttributeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_select_attribute_with_options(): void
{
$this->createTenant('acme', 'Acme', 'acme.com');
$response = $this->postJson('/api/tenants/acme/attributes', [
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
'metadata_schema' => [
'swatch' => ['type' => 'hex'],
],
'options' => [
[
'value' => 'red',
'label' => 'Red',
'sort_order' => 1,
'metadata' => ['hex' => '#ff0000'],
],
[
'value' => 'blue',
'label' => 'Blue',
'sort_order' => 2,
'metadata' => ['hex' => '#0000ff'],
],
],
]);
$response
->assertCreated()
->assertJsonPath('data.codigo', 'color')
->assertJsonPath('data.type', 'select')
->assertJsonPath('data.options.0.value', 'red')
->assertJsonPath('data.options.0.label', 'Red')
->assertJsonPath('data.options.1.metadata.hex', '#0000ff');
$this->assertDatabaseHas('attribute', [
'tenant_codigo' => 'acme',
'codigo' => 'color',
'type' => 'select',
]);
$this->assertDatabaseHas('attribute_options', [
'value' => 'red',
'label' => 'Red',
'sort_order' => 1,
]);
}
public function test_it_rejects_options_for_string_attributes(): void
{
$this->createTenant('acme', 'Acme', 'acme.com');
$response = $this->postJson('/api/tenants/acme/attributes', [
'codigo' => 'material',
'nombre' => 'Material',
'type' => 'string',
'options' => [
['label' => 'Cotton'],
],
]);
$response
->assertUnprocessable()
->assertJsonValidationErrors(['options']);
}
public function test_it_throws_exception_when_creating_non_select_attribute_with_options_directly_on_model(): void
{
$tenant = $this->createTenant('acme2', 'Acme 2', 'acme2.com');
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Options are only allowed for select and multiselect attributes.');
\App\Domains\Catalog\Models\Product::createAttribute($tenant, [
'codigo' => 'material2',
'nombre' => 'Material 2',
'type' => 'string',
'options' => [
['label' => 'Cotton', 'value' => 'cotton'],
],
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -0,0 +1,368 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Resources\CartItemResource;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class BundleCatalogItemTest extends TestCase
{
use RefreshDatabase;
private CatalogService $catalogService;
private CatalogInventoryService $inventoryService;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->catalogService = app(CatalogService::class);
$this->inventoryService = app(CatalogInventoryService::class);
$this->tenant = $this->createTenant('bundle-tenant');
}
public function test_it_creates_a_bundle_and_derives_its_inventory(): void
{
$shirt = $this->createStandardItem('shirt', 11);
$cap = $this->createStandardItem('cap', 3);
$bundle = $this->createBundle('training-kit', [
['catalog_item_id' => $shirt->id, 'quantity' => 2],
['catalog_item_id' => $cap->id, 'quantity' => 1],
]);
$this->assertSame(CatalogItemType::Bundle, $bundle->type);
$this->assertNull($bundle->inventory_id);
$this->assertNull($bundle->inventory_policy);
$this->assertCount(2, $bundle->bundleComponents);
$this->assertSame(3, $bundle->availableStock());
$this->inventoryService->reserve($bundle, 2);
$this->assertSame(4, $shirt->inventory->fresh()->reserved_stock);
$this->assertSame(2, $cap->inventory->fresh()->reserved_stock);
$this->inventoryService->release($bundle, 1);
$this->inventoryService->commit($bundle, 1);
$this->assertDatabaseHas('inventories', [
'id' => $shirt->inventory_id,
'real_stock' => 9,
'reserved_stock' => 0,
'sold_units' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $cap->inventory_id,
'real_stock' => 2,
'reserved_stock' => 0,
'sold_units' => 1,
]);
}
public function test_bundle_inventory_rolls_back_when_one_component_has_insufficient_stock(): void
{
$available = $this->createStandardItem('available', 10);
$scarce = $this->createStandardItem('scarce', 1);
$bundle = $this->createBundle('invalid-reservation', [
['catalog_item_id' => $available->id, 'quantity' => 2],
['catalog_item_id' => $scarce->id, 'quantity' => 1],
]);
try {
$this->inventoryService->reserve($bundle, 2);
$this->fail('The reservation should have failed.');
} catch (\InvalidArgumentException) {
$this->assertSame(0, $available->inventory->fresh()->reserved_stock);
$this->assertSame(0, $scarce->inventory->fresh()->reserved_stock);
}
}
public function test_bundle_supports_fixed_variants_and_unlimited_components(): void
{
$attribute = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
$variantItem = $this->catalogService->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'variant-component',
'nombre' => 'Variant component',
'precio' => 20,
'attribute_codes' => [$attribute->codigo],
'variants' => [
['real_stock' => 6, 'values' => ['size' => 'M']],
],
]);
$unlimited = $this->createStandardItem(
'unlimited-component',
0,
inventoryPolicy: InventoryPolicy::Unlimited,
);
try {
$this->createBundle('variant-without-selection', [
['catalog_item_id' => $variantItem->id, 'quantity' => 1],
]);
$this->fail('A variant component should require variant_id.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('components.0.variant_id', $exception->errors());
}
$variant = $variantItem->variants->firstOrFail();
$bundle = $this->createBundle('mixed-inventory-bundle', [
[
'catalog_item_id' => $variantItem->id,
'variant_id' => $variant->id,
'quantity' => 2,
],
['catalog_item_id' => $unlimited->id, 'quantity' => 5],
]);
$this->assertSame(3, $bundle->availableStock());
$this->inventoryService->reserve($bundle, 2);
$this->assertSame(4, $variant->inventory->fresh()->reserved_stock);
$this->assertSame(10, $unlimited->inventory->fresh()->reserved_stock);
$unlimitedOnly = $this->createBundle('unlimited-bundle', [
['catalog_item_id' => $unlimited->id, 'quantity' => 2],
]);
$this->assertNull($unlimitedOnly->availableStock());
$this->assertTrue($unlimitedOnly->isAvailable());
}
public function test_cart_treats_the_bundle_as_a_single_catalog_item(): void
{
$originalItem = $this->createStandardItem('original', 10);
$bundle = $this->createBundle('cart-kit', [
['catalog_item_id' => $originalItem->id, 'quantity' => 1],
]);
$cart = Cart::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'guest_token' => 'bundle-guest',
'status' => 'active',
]);
$cartItem = $cart->addItem($bundle->id, null, 1);
$this->assertSame($bundle->id, $cartItem->catalog_item_id);
$this->assertNull($cartItem->variant_id);
$this->assertArrayNotHasKey(
'components',
CartItemResource::make($cartItem->load('catalogItem.attachments'))->resolve(),
);
$cart->updateItem($cartItem->id, 2);
$this->assertSame(2, $originalItem->inventory->fresh()->reserved_stock);
$cart->removeItem($cartItem->id);
$this->assertSame(0, $originalItem->inventory->fresh()->reserved_stock);
}
public function test_checkout_snapshots_only_the_bundle_and_commits_component_inventory(): void
{
$component = $this->createStandardItem('checkout-component', 10);
$bundle = $this->createBundle('checkout-kit', [
['catalog_item_id' => $component->id, 'quantity' => 2],
], '100.00');
$user = User::factory()->create();
$cart = Cart::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem($bundle->id, null, 2);
$checkoutService = app(CheckoutService::class);
$purchase = $checkoutService->startCheckout($this->tenant, $user->id, [
'cart_id' => $cart->id,
'dni' => '12345678',
'telefono' => '+54 9 341 555-0000',
'nombre_apellido' => 'Bundle Buyer',
'email' => 'bundle@example.com',
]);
$purchase->update(['payment_method' => 'transfer']);
$checkoutService->confirmPurchase($checkoutService->completePurchase($purchase));
$this->assertDatabaseCount('compra_items', 1);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'source_catalog_item_id' => $bundle->id,
'source_variant_id' => null,
'nombre' => $bundle->nombre,
'cantidad' => 2,
'precio_unitario' => '100.00',
'total' => '200.00',
]);
$this->assertDatabaseHas('inventories', [
'id' => $component->inventory_id,
'real_stock' => 6,
'reserved_stock' => 0,
'sold_units' => 4,
]);
}
public function test_bundle_validation_rejects_invalid_shapes(): void
{
$standard = $this->createStandardItem('standard', 10);
$otherTenant = $this->createTenant('other-tenant');
$foreign = $this->createStandardItem('foreign', 10, $otherTenant);
foreach ([
[],
[['catalog_item_id' => $foreign->id, 'quantity' => 1]],
] as $components) {
try {
$this->createBundle('invalid-'.count($components), $components);
$this->fail('The invalid bundle should not have been created.');
} catch (ValidationException) {
$this->assertTrue(true);
}
}
$bundle = $this->createBundle('valid-bundle', [
['catalog_item_id' => $standard->id, 'quantity' => 1],
]);
$this->expectException(ValidationException::class);
$this->createBundle('nested-bundle', [
['catalog_item_id' => $bundle->id, 'quantity' => 1],
]);
}
public function test_catalog_api_creates_and_returns_bundle_details(): void
{
$component = $this->createStandardItem('api-component', 8);
$bundleId = $this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
'type' => CatalogItemType::Bundle->value,
'slug' => 'api-bundle',
'nombre' => 'API Bundle',
'precio' => 75,
'components' => [
['catalog_item_id' => $component->id, 'quantity' => 2],
],
])
->assertCreated()
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->json('data.id');
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
->assertOk()
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->assertJsonPath('data.stock_tecnico', 4)
->assertJsonCount(1, 'data.components')
->assertJsonPath('data.components.0.catalog_item_id', $component->id)
->assertJsonPath('data.components.0.variant_id', null)
->assertJsonPath('data.components.0.quantity', 2);
$this->postJson("/api/tenants/{$this->tenant->codigo}/catalog-items", [
'type' => CatalogItemType::Bundle->value,
'slug' => 'bundle-with-stock',
'nombre' => 'Invalid Bundle',
'precio' => 75,
'real_stock' => 10,
'components' => [
['catalog_item_id' => $component->id, 'quantity' => 1],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['real_stock']);
}
public function test_bundle_components_are_deleted_with_the_bundle(): void
{
$component = $this->createStandardItem('deletion-component', 5);
$bundle = $this->createBundle('deletable-bundle', [
['catalog_item_id' => $component->id, 'quantity' => 1],
]);
$this->catalogService->delete($bundle);
$this->assertDatabaseMissing('catalog_items', ['id' => $bundle->id]);
$this->assertDatabaseMissing('bundle_components', [
'bundle_catalog_item_id' => $bundle->id,
]);
$this->assertDatabaseHas('catalog_items', ['id' => $component->id]);
}
private function createStandardItem(
string $slug,
int $stock,
?Tenant $tenant = null,
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): CatalogItem {
$tenant ??= $this->tenant;
return $this->catalogService->create([
'tenant_code' => $tenant->codigo,
'slug' => $slug,
'nombre' => ucfirst($slug),
'precio' => '25.00',
'inventory_policy' => $inventoryPolicy,
'real_stock' => $stock,
]);
}
/** @param array<int, array<string, mixed>> $components */
private function createBundle(
string $slug,
array $components,
string $price = '50.00',
): CatalogItem {
return $this->catalogService->create([
'tenant_code' => $this->tenant->codigo,
'type' => CatalogItemType::Bundle->value,
'slug' => $slug,
'nombre' => ucfirst($slug),
'precio' => $price,
'components' => $components,
]);
}
private function createTenant(string $code): Tenant
{
$header = Attachment::query()->create([
'path' => "test/{$code}-header.png",
'filename' => 'header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footer = Attachment::query()->create([
'path' => "test/{$code}-footer.png",
'filename' => 'footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $header->id,
'footer_logo_id' => $footer->id,
]);
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CatalogControllerTest extends TestCase
{
use RefreshDatabase;
public function test_row_and_column_with_cart_return_item_details_variants_and_technical_stock(): void
{
$tenant = $this->createTenant('catalog-index');
$row = $this->createGroup($tenant, ProductLayout::Row, 'Row', 2);
$cart = $this->createGroup($tenant, ProductLayout::ColumnWithCart, 'Cart', 1);
$directInventory = Inventory::query()->create([
'real_stock' => 10,
'reserved_stock' => 2,
]);
$directItem = $this->createItem($tenant, 'Direct', $directInventory);
$row->featuredItems()->create(['catalog_item_id' => $directItem->id]);
$variantItem = $this->createItem($tenant, 'Variants');
$firstInventory = Inventory::query()->create([
'real_stock' => 5,
'reserved_stock' => 1,
]);
$secondInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 1,
]);
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog");
$response
->assertOk()
->assertJsonCount(2)
->assertJsonPath('0.title', 'Cart')
->assertJsonPath('0.layout', ProductLayout::ColumnWithCart->value)
->assertJsonPath('0.items.meta.current_page', 1)
->assertJsonPath('0.items.data.0.nombre', 'Variants')
->assertJsonPath('0.items.data.0.descripcion', 'Variants description')
->assertJsonPath('0.items.data.0.precio', '100.00')
->assertJsonPath('0.items.data.0.stock_tecnico', 7)
->assertJsonCount(2, '0.items.data.0.variants')
->assertJsonPath('0.items.data.0.variants.0.stock_tecnico', 4)
->assertJsonPath('0.items.data.0.variants.1.stock_tecnico', 3)
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.stock_tecnico', 8)
->assertJsonCount(0, '1.items.data.0.variants');
}
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('catalog-images');
$group = $this->createGroup($tenant, ProductLayout::ColumnWithImage, 'Images');
$directItem = $this->createItem($tenant, 'Direct image');
$directImage = $this->createAttachment('direct');
$directItem->attachments()->attach($directImage, ['orden' => 0]);
$group->featuredItems()->create([
'catalog_item_id' => $directItem->id,
'order' => 0,
]);
$variantItem = $this->createItem($tenant, 'Variant image');
$variantInventory = Inventory::query()->create();
$variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]);
$variantImage = $this->createAttachment('variant');
$variant->attachments()->attach($variantImage, ['orden' => 0]);
$group->featuredItems()->create([
'catalog_item_id' => $variantItem->id,
'order' => 1,
]);
$emptyItem = $this->createItem($tenant, 'No image');
$group->featuredItems()->create([
'catalog_item_id' => $emptyItem->id,
'order' => 2,
]);
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog");
$response
->assertOk()
->assertJsonPath('0.layout', ProductLayout::ColumnWithImage->value)
->assertJsonPath('0.items.data.0.nombre', 'Direct image')
->assertJsonPath('0.items.data.0.precio', '100.00')
->assertJsonPath('0.items.data.0.image', fn (?string $url): bool => str_contains($url ?? '', 'direct.png'))
->assertJsonPath('0.items.data.1.image', fn (?string $url): bool => str_contains($url ?? '', 'variant.png'))
->assertJsonPath('0.items.data.2.image', null);
}
public function test_index_always_returns_page_one_and_group_endpoint_returns_other_pages(): void
{
$tenant = $this->createTenant('catalog-pagination');
$group = $this->createGroup($tenant, ProductLayout::Row, 'Paginated');
foreach (range(1, 13) as $number) {
$item = $this->createItem($tenant, "Item {$number}");
$group->featuredItems()->create([
'catalog_item_id' => $item->id,
'order' => $number,
]);
}
$this->getJson("/api/tenants/{$tenant->codigo}/catalog?page=2")
->assertOk()
->assertJsonPath('0.items.meta.current_page', 1)
->assertJsonPath('0.items.meta.last_page', 2)
->assertJsonPath('0.items.meta.per_page', 12)
->assertJsonPath('0.items.meta.total', 13)
->assertJsonCount(12, '0.items.data')
->assertJsonPath('0.items.data.0.nombre', 'Item 1');
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items?page=2"
)
->assertOk()
->assertJsonPath('meta.current_page', 2)
->assertJsonPath('meta.last_page', 2)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.nombre', 'Item 13');
}
private function createGroup(
Tenant $tenant,
ProductLayout $layout,
string $name,
int $order = 0,
): FeaturedGroup {
return FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'product_layout' => $layout,
'group_name' => $name,
'group_order' => $order,
]);
}
private function createItem(
Tenant $tenant,
string $name,
?Inventory $inventory = null,
): CatalogItem {
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory?->id,
'slug' => str($name)->slug()->toString(),
'nombre' => $name,
'descripcion' => "{$name} description",
'precio' => 100,
]);
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CatalogItemControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_catalog_item_with_item_and_variant_images(): void
{
Storage::fake('s3');
$tenant = $this->createTenant();
$attribute = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'shirt',
'nombre' => 'Shirt',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
'images' => [$image, $image],
'variants' => [
[
'real_stock' => 5,
'values' => ['size' => 'M'],
'images' => [$image],
],
],
]);
$response
->assertCreated()
->assertJsonPath('data.nombre', 'Shirt')
->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants')
->assertJsonCount(1, 'data.variants.0.images');
$item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail();
$variant = $item->variants()->firstOrFail();
$this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all());
$this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all());
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'orden' => 0,
]);
}
public function test_it_validates_images_before_creating_the_catalog_item(): void
{
$tenant = $this->createTenant('validation');
$this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'invalid-image',
'nombre' => 'Invalid image',
'precio' => 100,
'real_stock' => 1,
'images' => ['not-an-image'],
])->assertUnprocessable()->assertJsonValidationErrors('images.0');
$this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-image']);
}
private function createTenant(string $code = 'catalog-controller'): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class CatalogItemDetailControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_item_images_and_stock_when_the_item_has_no_variants(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-direct');
$inventory = Inventory::query()->create([
'real_stock' => 10,
'reserved_stock' => 3,
]);
$item = $this->createItem($tenant, 'Direct item', $inventory);
$itemImage = $this->createAttachment('direct-item');
$item->attachments()->attach($itemImage, ['orden' => 0]);
$response = $this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}"
);
$response
->assertOk()
->assertJsonPath('data.stock_tecnico', 7)
->assertJsonCount(0, 'data.variants')
->assertJsonCount(1, 'data.images');
$response->assertJsonMissingPath('data.selected_variant');
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
$item = $this->createItem($tenant, 'Variant item');
$itemImage = $this->createAttachment('item-image');
$item->attachments()->attach($itemImage, ['orden' => 0]);
$firstVariant = $this->createVariant($item, 0, 0);
$secondVariant = $this->createVariant($item, 8, 2);
$firstImage = $this->createAttachment('first-variant');
$secondImage = $this->createAttachment('second-variant');
$firstVariant->attachments()->attach($firstImage, ['orden' => 0]);
$secondVariant->attachments()->attach($secondImage, ['orden' => 0]);
$response = $this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}"
);
$response
->assertOk()
->assertJsonPath('data.selected_variant.id', $firstVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 0)
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images');
$this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-requested');
$item = $this->createItem($tenant, 'Shirt');
$size = Attribute::query()->create([
'tenant_codigo' => $tenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
$itemSize = ItemAttribute::query()->create([
'catalog_item_id' => $item->id,
'attribute_id' => $size->id,
]);
$size->options()->createMany([
['value' => 'S', 'label' => 'Small', 'sort_order' => 0],
['value' => 'M', 'label' => 'Medium', 'sort_order' => 1],
['value' => 'L', 'label' => 'Large', 'sort_order' => 2],
]);
$firstVariant = $this->createVariant($item, 5, 1);
$secondVariant = $this->createVariant($item, 9, 2);
$firstVariant->definitions()->create([
'item_attribute_id' => $itemSize->id,
'value' => 'S',
]);
$secondVariant->definitions()->create([
'item_attribute_id' => $itemSize->id,
'value' => 'M',
]);
$secondImage = $this->createAttachment('selected-variant');
$secondVariant->attachments()->attach($secondImage, ['orden' => 0]);
$response = $this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$secondVariant->id}"
);
$response
->assertOk()
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.stock_tecnico', 4)
->assertJsonPath('data.variants.0.values.size', 'S')
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonPath('data.variants.1.stock_tecnico', 7)
->assertJsonPath('data.variants.1.values.size', 'M')
->assertJsonPath('data.attributes.0.codigo', 'size')
->assertJsonPath('data.attributes.0.options.0.value', 'S')
->assertJsonPath('data.attributes.0.options.1.value', 'M')
->assertJsonCount(2, 'data.attributes.0.options')
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', 7)
->assertJsonPath('data.selected_variant.values.size', 'M')
->assertJsonCount(1, 'data.selected_variant.images');
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images')
->assertJsonMissingPath('data.variants.0.images')
->assertJsonMissingPath('data.variants.1.images');
$this->assertStringContainsString(
$secondImage->path,
$response->json('data.selected_variant.images.0'),
);
}
public function test_it_rejects_an_invalid_or_foreign_variant(): void
{
$tenant = $this->createTenant('detail-invalid');
$item = $this->createItem($tenant, 'Requested item');
$otherItem = $this->createItem($tenant, 'Other item');
$foreignVariant = $this->createVariant($otherItem, 5, 0);
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id=abc"
)->assertUnprocessable()->assertJsonValidationErrors('variant_id');
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$foreignVariant->id}"
)->assertNotFound();
}
public function test_it_returns_null_technical_stock_for_unlimited_variants(): void
{
$tenant = $this->createTenant('detail-unlimited');
$item = $this->createItem($tenant, 'Unlimited item', policy: InventoryPolicy::Unlimited);
$variant = $this->createVariant($item, 0, 20);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.selected_variant.id', $variant->id)
->assertJsonPath('data.selected_variant.stock_tecnico', null)
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonPath('data.variants.0.stock_tecnico', null);
}
private function createItem(
Tenant $tenant,
string $name,
?Inventory $inventory = null,
InventoryPolicy $policy = InventoryPolicy::Tracked,
): CatalogItem {
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory?->id,
'slug' => str($name)->slug()->toString(),
'nombre' => $name,
'descripcion' => "{$name} description",
'precio' => 100,
'inventory_policy' => $policy,
]);
}
private function createVariant(CatalogItem $item, int $realStock, int $reservedStock): Variant
{
$inventory = Inventory::query()->create([
'real_stock' => $realStock,
'reserved_stock' => $reservedStock,
]);
return $item->variants()->create(['inventory_id' => $inventory->id]);
}
private function createTenant(string $code): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CatalogSchemaTest extends TestCase
{
use RefreshDatabase;
public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void
{
$this->assertFalse(Schema::hasTable('productos'));
$this->assertFalse(Schema::hasTable('productos_variantes'));
$this->assertFalse(Schema::hasTable('products_attributes'));
$this->assertFalse(Schema::hasTable('bundles'));
$this->assertFalse(Schema::hasTable('bundle_items'));
$this->assertTrue(Schema::hasTable('variantes'));
$this->assertTrue(Schema::hasTable('item_attributes'));
$this->assertTrue(Schema::hasTable('variant_values'));
}
public function test_catalog_items_contains_catalog_classification_and_optional_inventory(): void
{
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'category_id',
'brand_id',
'inventory_id',
'type',
'slug',
'nombre',
'descripcion',
'precio',
'inventory_policy',
'has_tickets',
'maximum_use_date',
'minimum_use_date',
], Schema::getColumnListing('catalog_items'));
}
public function test_bundle_components_directly_link_catalog_items(): void
{
$this->assertFalse(Schema::hasTable('bundle_compositions'));
$this->assertEqualsCanonicalizing([
'id',
'bundle_catalog_item_id',
'component_catalog_item_id',
'component_variant_id',
'quantity',
], Schema::getColumnListing('bundle_components'));
$this->assertFalse(Schema::hasColumn('carrito_items', 'bundle_composition_id'));
}
public function test_inventories_have_no_polymorphic_columns(): void
{
$this->assertEqualsCanonicalizing([
'id',
'sold_units',
'reserved_stock',
'real_stock',
], Schema::getColumnListing('inventories'));
}
public function test_featured_catalog_tables_replace_legacy_group_items(): void
{
$this->assertFalse(Schema::hasTable('group_items'));
$this->assertFalse(Schema::hasTable('featured_variants'));
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'product_layout',
'group_name',
'group_order',
], Schema::getColumnListing('featured_groups'));
$this->assertEqualsCanonicalizing([
'id',
'featured_group_id',
'catalog_item_id',
'order',
], Schema::getColumnListing('featured_items'));
}
public function test_catalog_and_variant_attachments_share_the_catalog_pivot(): void
{
$this->assertFalse(Schema::hasTable('productos_attachments'));
$this->assertFalse(Schema::hasTable('variantes_attachments'));
$this->assertEqualsCanonicalizing([
'id',
'variant_id',
'catalog_item_id',
'attachment_id',
'orden',
], Schema::getColumnListing('catalog_items_attachments'));
}
public function test_catalog_item_can_have_no_variants_and_variant_requires_inventory(): void
{
$headerLogo = Attachment::query()->create([
'path' => 'test/header.png',
'filename' => 'header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerLogo = Attachment::query()->create([
'path' => 'test/footer.png',
'filename' => 'footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::query()->create([
'codigo' => 'test-tenant',
'nombre' => 'Test Tenant',
'dominio' => 'test.local',
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
$inventory = Inventory::query()->create();
$item = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory->id,
'slug' => 'item',
'nombre' => 'Item',
'precio' => 10,
]);
$variantInventory = Inventory::query()->create();
$variant = $item->variants()->create(['inventory_id' => $variantInventory->id]);
$itemAttachment = Attachment::query()->create([
'path' => 'test/item.png',
'filename' => 'item.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$variantAttachment = Attachment::query()->create([
'path' => 'test/variant.png',
'filename' => 'variant.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$item->attachments()->attach($itemAttachment, ['orden' => 2]);
$variant->attachments()->attach($variantAttachment, ['orden' => 3]);
$this->assertTrue($variant->catalogItem->is($item));
$this->assertTrue($variant->inventory->is($variantInventory));
$this->assertTrue($item->attachments()->firstOrFail()->is($itemAttachment));
$this->assertTrue($variant->attachments()->firstOrFail()->is($variantAttachment));
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,
'variant_id' => null,
'attachment_id' => $itemAttachment->id,
'orden' => 2,
]);
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,
'variant_id' => $variant->id,
'attachment_id' => $variantAttachment->id,
'orden' => 3,
]);
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class CatalogServiceTest extends TestCase
{
use RefreshDatabase;
private CatalogService $service;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->service = app(CatalogService::class);
$this->tenant = $this->createTenant();
}
public function test_it_creates_an_item_with_direct_inventory_when_it_has_no_variants(): void
{
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'simple-item',
'nombre' => 'Simple item',
'precio' => 100,
'real_stock' => 12,
]);
$this->assertNotNull($item->inventory_id);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertCount(0, $item->variants);
$this->assertSame(12, $item->inventory->real_stock);
$this->assertSame(0, $item->inventory->reserved_stock);
$this->assertSame(0, $item->inventory->sold_units);
}
public function test_it_creates_variant_inventory_without_direct_item_inventory(): void
{
$attribute = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'variant-item',
'nombre' => 'Variant item',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
'variants' => [
[
'real_stock' => 5,
'values' => [$attribute->codigo => 'S'],
],
[
'real_stock' => 8,
'values' => [$attribute->codigo => 'M'],
],
],
]);
$this->assertNull($item->inventory_id);
$this->assertCount(2, $item->variants);
$this->assertCount(1, $item->itemAttributes);
$this->assertTrue($item->itemAttributes->first()->attribute->is($attribute));
$this->assertSame([5, 8], $item->variants->pluck('inventory.real_stock')->all());
$this->assertSame(
['S', 'M'],
$item->variants->pluck('definitions')->flatten()->pluck('value')->all(),
);
foreach ($item->variants as $variant) {
$this->assertNotNull($variant->inventory_id);
$this->assertSame(0, $variant->inventory->reserved_stock);
$this->assertSame(0, $variant->inventory->sold_units);
}
}
public function test_it_rejects_direct_inventory_together_with_variants(): void
{
$attribute = $this->createAttribute('size');
try {
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'invalid-item',
'nombre' => 'Invalid item',
'precio' => 100,
'real_stock' => 10,
'attribute_codes' => [$attribute->codigo],
'variants' => [
['real_stock' => 5],
],
]);
$this->fail('A validation exception was not thrown.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('real_stock', $exception->errors());
}
$this->assertDatabaseMissing('catalog_items', ['slug' => 'invalid-item']);
$this->assertSame(0, CatalogItem::query()->count());
$this->assertSame(0, Variant::query()->count());
$this->assertSame(0, Inventory::query()->count());
}
public function test_it_rejects_variants_when_attribute_codes_are_empty(): void
{
$this->expectException(ValidationException::class);
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'variants-without-attributes',
'nombre' => 'Variants without attributes',
'precio' => 100,
'variants' => [
['real_stock' => 5],
],
]);
}
public function test_it_requires_variants_when_attribute_codes_are_present(): void
{
$attribute = $this->createAttribute('color');
$this->expectException(ValidationException::class);
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'attributes-without-variants',
'nombre' => 'Attributes without variants',
'precio' => 100,
'attribute_codes' => [$attribute->codigo],
]);
}
public function test_it_only_resolves_attribute_codes_from_the_item_tenant(): void
{
$otherTenant = $this->createTenant('other-tenant');
Attribute::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'codigo' => 'size',
'nombre' => 'Size',
'type' => FieldType::String,
]);
try {
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'foreign-attribute',
'nombre' => 'Foreign attribute',
'precio' => 100,
'attribute_codes' => ['size'],
'variants' => [
[
'real_stock' => 5,
'values' => ['size' => 'M'],
],
],
]);
$this->fail('A validation exception was not thrown.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey('attribute_codes', $exception->errors());
}
$this->assertDatabaseMissing('catalog_items', ['slug' => 'foreign-attribute']);
$this->assertSame(0, Inventory::query()->count());
}
private function createTenant(string $code = 'catalog-service'): Tenant
{
$headerLogo = $this->createAttachment("{$code}-header");
$footerLogo = $this->createAttachment("{$code}-footer");
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.local",
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
private function createAttribute(string $code): Attribute
{
return Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => $code,
'nombre' => ucfirst($code),
'type' => FieldType::String,
]);
}
}

View File

@@ -1,114 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class GroupItemTest extends TestCase
{
use RefreshDatabase;
private FeaturedGroup $featuredGroup;
private ProductVariant $variant;
private Bundle $bundle;
protected function setUp(): void
{
parent::setUp();
$headerAttachment = $this->createAttachment('header.png');
$footerAttachment = $this->createAttachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'group-test',
'nombre' => 'Group Test',
'dominio' => 'group.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#555555',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$category = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Group items',
]);
$product = Product::query()->create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => $category->id,
'slug' => 'group-item-product',
'nombre' => 'Group Item Product',
'precio' => 100,
]);
$this->variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'stock' => 10,
]);
$this->bundle = Bundle::query()->create([
'tenant_codigo' => $tenant->codigo,
'nombre' => 'Group Item Bundle',
'precio' => 150,
]);
$this->featuredGroup = FeaturedGroup::query()->create([
'tenant_codigo' => $tenant->codigo,
'group_name' => 'Featured',
'product_layout' => 'row',
'group_order' => 0,
]);
}
public function test_a_group_item_can_reference_a_product_variant(): void
{
$groupItem = $this->variant->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 1,
]);
$this->assertInstanceOf(ProductVariant::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->variant));
$this->assertTrue($this->variant->groupItems->first()->is($groupItem));
}
public function test_a_group_item_can_reference_a_bundle(): void
{
$groupItem = $this->bundle->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 2,
]);
$this->assertInstanceOf(Bundle::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->bundle));
$this->assertTrue($this->bundle->groupItems->first()->is($groupItem));
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tests/'.$filename,
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}

View File

@@ -1,878 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProductControllerTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private Brand $brand;
private Attribute $sizeAttr;
private Attribute $colorAttr;
private Attribute $extraAttr;
protected function setUp(): void
{
parent::setUp();
$this->tenant = $this->createTenant('acme', 'Acme Inc.', 'acme.com');
$this->brand = Brand::create([
'tenant_codigo' => $this->tenant->codigo,
'nombre' => 'Adidas',
]);
// Create attributes for variants
$this->sizeAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'talle',
'nombre' => 'Talle',
'is_required' => true,
'type' => 'select',
]);
$this->sizeAttr->options()->createMany([
['value' => 'S', 'label' => 'S'],
['value' => '38', 'label' => '38'],
]);
$this->colorAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'color',
'nombre' => 'Color',
'is_required' => true,
'type' => 'select',
]);
$this->colorAttr->options()->createMany([
['value' => 'Azul', 'label' => 'Azul'],
['value' => 'Rojo', 'label' => 'Rojo'],
]);
$this->extraAttr = Attribute::create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'extra',
'nombre' => 'Extra Attribute',
'is_required' => false,
'type' => 'string',
]);
}
public function test_it_creates_product_with_attributes_and_then_creates_variants(): void
{
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'remera-sport',
'nombre' => 'Remera Sport',
'descripcion' => 'Remera para hacer deportes',
'precio' => 15000.00,
'attribute_ids' => [
$this->extraAttr->id,
$this->sizeAttr->id,
$this->colorAttr->id,
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload);
$response->assertCreated();
// Assert JSON structure
$response->assertJsonPath('data.nombre', 'Remera Sport');
// Assert Database
$this->assertDatabaseHas('productos', [
'tenant_codigo' => $this->tenant->codigo,
'slug' => 'remera-sport',
]);
$product = Product::where('slug', 'remera-sport')->firstOrFail();
// Assert that products_attributes has both explicit extraAttr and those from variants (sizeAttr, colorAttr)
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->extraAttr->id,
]);
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->sizeAttr->id,
]);
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->colorAttr->id,
]);
$productAttributes = [
'size' => $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail(),
'color' => $product->productAttributes()->where('attribute_id', $this->colorAttr->id)->firstOrFail(),
];
// Create a variant
$variantPayload = [
'stock' => 10,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
[
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Azul',
],
],
];
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
$variantResponse->assertCreated();
$this->assertDatabaseHas('productos_variantes', [
'producto_id' => $product->id,
'stock_real' => 10,
]);
$variantS = ProductVariant::where('producto_id', $product->id)->where('stock_real', 10)->firstOrFail();
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
]);
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Azul',
]);
}
public function test_it_updates_product_attributes_independently(): void
{
// 1. Create a product with extraAttr
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->extraAttr->id]);
// 2. Perform update payload - change name and update attribute_ids to sizeAttr
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pantalon-cargo-new-slug',
'nombre' => 'Pantalon Cargo V2',
'precio' => 22000.00,
'attribute_ids' => [$this->sizeAttr->id],
];
$response = $this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}",
$payload
);
$response->assertOk();
// Assert updated values
$response->assertJsonPath('data.nombre', 'Pantalon Cargo V2');
// Check DB state
// extraAttr must be detached
$this->assertDatabaseMissing('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->extraAttr->id,
]);
// sizeAttr must be attached
$this->assertDatabaseHas('products_attributes', [
'product_id' => $product->id,
'attribute_id' => $this->sizeAttr->id,
]);
}
public function test_it_rejects_variant_creation_with_attributes_not_associated_with_product(): void
{
// Create product with only sizeAttr associated
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->sizeAttr->id]);
$sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail();
// Attempt to create variant with colorAttr (which is not associated with the product)
$variantPayload = [
'stock' => 5,
'definitions' => [
[
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
],
[
'products_attribute_id' => 99999, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
}
public function test_it_rejects_variant_update_with_attributes_not_associated_with_product(): void
{
// Create product with only sizeAttr associated
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$product->attributes()->sync([$this->sizeAttr->id]);
$sizeProductAttr = $product->productAttributes()->where('attribute_id', $this->sizeAttr->id)->firstOrFail();
$variant = $product->variants()->create([
'stock' => 5,
]);
$variant->definitions()->create([
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
]);
// Attempt to update variant with colorAttr (which is not associated with the product)
$variantPayload = [
'stock' => 5,
'definitions' => [
[
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
],
[
'products_attribute_id' => 99999, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
}
public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'short-running',
'nombre' => 'Short Running',
'precio' => 8000.00,
]);
$v1 = $product->variants()->create([
'stock' => 5,
]);
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'short-running',
'nombre' => 'Short Running Updated',
'precio' => 9000.00,
];
$response = $this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}",
$payload
);
$response->assertOk();
$this->assertDatabaseHas('productos', [
'id' => $product->id,
'nombre' => 'Short Running Updated',
]);
// Variant should still exist untouched
$this->assertDatabaseHas('productos_variantes', [
'id' => $v1->id,
'stock_real' => 5,
]);
}
public function test_it_rejects_variants_with_invalid_attributes(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pantalon-cargo',
'nombre' => 'Pantalon Cargo',
'precio' => 20000.00,
]);
$payload = [
'stock' => 10,
'definitions' => [
[
'products_attribute_id' => 99999, // Non-existent ID
'value' => 'S',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $payload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.0.products_attribute_id']);
}
public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void
{
// 1. Create a select attribute with options
$selectAttr = Product::createAttribute($this->tenant, [
'codigo' => 'tamanho',
'nombre' => 'Tamanho',
'type' => 'select',
'options' => [
['value' => 'P', 'label' => 'Piqueno'],
['value' => 'M', 'label' => 'Medio'],
],
]);
// 2. Create product and associate attribute
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-prod-validation',
'nombre' => 'Test Prod Validation',
'precio' => 100.00,
]);
$product->attributes()->sync([$selectAttr->id]);
$productAttr = $product->productAttributes()->where('attribute_id', $selectAttr->id)->firstOrFail();
// 3. Expect exception when creating variant with invalid value 'G'
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The value 'G' is not a valid option for the select attribute 'Tamanho'.");
$product->createVariant([
'stock' => 5,
'definitions' => [
[
'products_attribute_id' => $productAttr->id,
'value' => 'G', // Invalid value
],
],
]);
}
public function test_it_returns_product_detail_with_variant_mapping_and_default_selected_variant(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-show',
'nombre' => 'Test Product Show',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$this->colorAttr->options()->create([
'value' => 'Verde',
'label' => 'Verde',
]);
$variant = $product->createVariant([
'stock' => 0,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
[
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Azul',
],
],
]);
$secondVariant = $product->createVariant([
'stock' => 4,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => '38',
],
[
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Rojo',
],
],
]);
$variantAttachment = $this->createAttachment('attachments/selected-variant.png');
$secondVariant->attachments()->attach($variantAttachment->id);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}");
$response->assertOk();
$response->assertJsonStructure([
'data' => [
'id',
'nombre',
'attributes',
'variants_map' => [
'*' => [
'variant_id',
'cantidad_maxima',
'attributes' => [
'talle',
'color',
],
],
],
'variant' => [
'id',
'cantidad_maxima',
'definitions',
'images',
],
],
]);
$response->assertJsonMissingPath('data.variants');
$response->assertJsonPath('data.variants_map.0.variant_id', $variant->id);
$response->assertJsonPath('data.variants_map.0.cantidad_maxima', 0);
$response->assertJsonPath('data.variants_map.0.attributes.talle', 'S');
$response->assertJsonPath('data.variants_map.0.attributes.color', 'Azul');
$response->assertJsonPath('data.variants_map.1.variant_id', $secondVariant->id);
$response->assertJsonPath('data.variants_map.1.cantidad_maxima', 4);
$response->assertJsonPath('data.variants_map.1.attributes.talle', '38');
$response->assertJsonPath('data.variants_map.1.attributes.color', 'Rojo');
$response->assertJsonCount(2, 'data.variants_map');
$response->assertJsonPath('data.variant.id', $secondVariant->id);
$response->assertJsonPath('data.variant.cantidad_maxima', 4);
$response->assertJsonPath('data.variant.definitions.talle', '38');
$response->assertJsonPath('data.variant.definitions.color', 'Rojo');
$response->assertJsonCount(1, 'data.variant.images');
$this->assertStringContainsString($variantAttachment->path, $response->json('data.variant.images.0'));
$colorAttribute = collect($response->json('data.attributes'))->firstWhere('codigo', 'color');
$this->assertNotNull($colorAttribute);
$this->assertEqualsCanonicalizing(
['Azul', 'Rojo'],
collect($colorAttribute['options'])->pluck('value')->all()
);
$attributesResponse = $this->getJson("/api/tenants/{$this->tenant->codigo}/attributes");
$attributesResponse->assertOk();
$masterColorAttribute = collect($attributesResponse->json('data'))->firstWhere('codigo', 'color');
$this->assertNotNull($masterColorAttribute);
$this->assertEqualsCanonicalizing(
['Azul', 'Rojo', 'Verde'],
collect($masterColorAttribute['options'])->pluck('value')->all()
);
}
public function test_it_returns_requested_variant_in_product_detail(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-requested-variant',
'nombre' => 'Test Product Requested Variant',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$firstVariant = $product->createVariant([
'stock' => 5,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
],
]);
$secondVariant = $product->createVariant([
'stock' => 7,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => '38',
],
],
]);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$firstVariant->id}");
$response->assertOk();
$response->assertJsonPath('data.variant.id', $firstVariant->id);
$response->assertJsonPath('data.variant.cantidad_maxima', 5);
$response->assertJsonPath('data.variants_map.1.variant_id', $secondVariant->id);
}
public function test_it_rejects_product_detail_variant_id_from_another_product(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-invalid-variant',
'nombre' => 'Test Product Invalid Variant',
'precio' => 100.00,
]);
$otherProduct = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-other-variant',
'nombre' => 'Test Product Other Variant',
'precio' => 100.00,
]);
$otherVariant = $otherProduct->variants()->create(['stock' => 3]);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$otherVariant->id}");
$response->assertNotFound();
}
public function test_selected_variant_images_fall_back_to_product_images(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-fallback-images',
'nombre' => 'Test Product Fallback Images',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$productAttachment = $this->createAttachment('attachments/product-fallback.png');
$product->attachments()->attach($productAttachment->id);
$variant = $product->createVariant([
'stock' => 6,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
],
]);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}");
$response->assertOk();
$response->assertJsonPath('data.variant.id', $variant->id);
$response->assertJsonCount(1, 'data.variant.images');
$this->assertStringContainsString($productAttachment->path, $response->json('data.variant.images.0'));
}
/**
* @return array{size: ProductAttribute, color: ProductAttribute}
*/
private function syncVariantAttributes(Product $product): array
{
$product->attributes()->sync([$this->sizeAttr->id, $this->colorAttr->id]);
return [
'size' => $product->productAttributes()
->where('attribute_id', $this->sizeAttr->id)
->firstOrFail(),
'color' => $product->productAttributes()
->where('attribute_id', $this->colorAttr->id)
->firstOrFail(),
];
}
public function test_it_creates_default_variant_on_product_creation(): void
{
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'pelota-mundial-test',
'nombre' => 'Pelota Mundial Test',
'precio' => 45000.00,
'stock' => 15,
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload);
$response->assertCreated();
$product = Product::where('slug', 'pelota-mundial-test')->firstOrFail();
// Should have exactly 1 variant
$this->assertEquals(1, $product->variants()->count());
$variant = $product->variants()->first();
$this->assertEquals(15, $variant->stock);
$this->assertTrue($variant->is_placeholder);
// Should have no definitions
$this->assertEquals(0, $variant->definitions()->count());
}
public function test_it_removes_default_variant_when_creating_real_variant(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-default-variant-lifecycle',
'nombre' => 'Test Default Variant Lifecycle',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$defaultVariant = $product->variants()->create([
'stock' => 10,
'is_placeholder' => true,
]);
$this->assertEquals(1, $product->variants()->count());
// Create a real variant (with definitions)
$variantPayload = [
'stock' => 5,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
$response->assertCreated();
// The default variant should be deleted
$this->assertDatabaseMissing('productos_variantes', [
'id' => $defaultVariant->id,
]);
// Only the new variant should remain
$this->assertEquals(1, $product->variants()->count());
$newVariant = $product->variants()->first();
$this->assertEquals(5, $newVariant->stock);
$this->assertFalse($newVariant->is_placeholder);
}
public function test_it_restores_default_variant_when_all_variants_are_deleted(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-default-variant-restore',
'nombre' => 'Test Default Variant Restore',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$realVariant = $product->createVariant([
'stock' => 5,
'is_placeholder' => false,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
],
]);
$this->assertEquals(1, $product->variants()->count());
// Delete the real variant via API
$response = $this->deleteJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$realVariant->id}");
$response->assertNoContent();
// A default variant should be recreated with stock 0 and is_placeholder = true
$this->assertEquals(1, $product->variants()->count());
$defaultVariant = $product->variants()->first();
$this->assertEquals(0, $defaultVariant->stock);
$this->assertTrue($defaultVariant->is_placeholder);
$this->assertEquals(0, $defaultVariant->definitions()->count());
}
public function test_it_rejects_product_detail_when_requested_variant_has_no_stock(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-no-stock',
'nombre' => 'Test Product No Stock',
'precio' => 100.00,
]);
$productAttributes = $this->syncVariantAttributes($product);
$outOfStockVariant = $product->createVariant([
'stock' => 0,
'definitions' => [
[
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
],
]);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id={$outOfStockVariant->id}");
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['variant_id']);
$response->assertJsonPath('errors.variant_id.0', 'La variante seleccionada no tiene stock.');
}
public function test_it_rejects_product_detail_when_requested_variant_id_is_invalid_format(): void
{
$product = Product::create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'test-product-invalid-format',
'nombre' => 'Test Product Invalid Format',
'precio' => 100.00,
]);
$response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}?variant_id=abc");
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['variant_id']);
}
public function test_it_creates_an_unlimited_default_variant_and_exposes_inventory_fields(): void
{
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'unlimited-product',
'nombre' => 'Unlimited Product',
'precio' => 100,
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
]);
$response->assertCreated();
$product = Product::query()->where('slug', 'unlimited-product')->firstOrFail();
$variant = $product->variants()->firstOrFail();
$this->assertSame(InventoryPolicy::Unlimited, $variant->inventory_policy);
$this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}")
->assertOk()
->assertJsonPath('data.variant.id', $variant->id)
->assertJsonPath('data.variant.inventory_policy', InventoryPolicy::Unlimited->value)
->assertJsonPath('data.variant.cantidad_maxima', null)
->assertJsonPath('data.variant.cantidad_vendida', 0)
->assertJsonPath('data.variants_map.0.inventory_policy', InventoryPolicy::Unlimited->value)
->assertJsonPath('data.variants_map.0.cantidad_maxima', null)
->assertJsonPath('data.variants_map.0.cantidad_vendida', 0);
}
public function test_it_rejects_invalid_or_updated_inventory_policies(): void
{
$this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'invalid-policy',
'nombre' => 'Invalid Policy',
'precio' => 100,
'inventory_policy' => 'sometimes',
])->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']);
$product = Product::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'immutable-policy',
'nombre' => 'Immutable Policy',
'precio' => 100,
]);
$variant = $product->variants()->create([
'stock' => 0,
'inventory_policy' => InventoryPolicy::Unlimited->value,
]);
$this->putJson(
"/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}",
['inventory_policy' => InventoryPolicy::Tracked->value],
)->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']);
$this->assertSame(InventoryPolicy::Unlimited, $variant->fresh()->inventory_policy);
}
private function createAttachment(string $path): Attachment
{
return Attachment::create([
'key' => (string) Str::uuid(),
'path' => $path,
'filename' => basename($path),
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$headerAttachment = Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
return Tenant::create([
'codigo' => $codigo,
'nombre' => $nombre,
'dominio' => $dominio,
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
}
}

View File

@@ -1,229 +0,0 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProductVariantAttachmentTest extends TestCase
{
use RefreshDatabase;
public function test_it_can_associate_attachments_to_product_variants(): void
{
// 1. Create Tenant
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$headerAttachment = Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme Inc.',
'dominio' => 'acme.com',
'primary_color' => '#ffffff',
'secondary_color' => '#ffffff',
'danger_color' => '#ffffff',
'success_color' => '#ffffff',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
// 2. Create Product
$product = Product::create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => 1,
'slug' => 'test-product',
'nombre' => 'Test Product',
'descripcion' => 'A test product description',
'precio' => 99.99,
]);
// 3. Create Variant
$variant = ProductVariant::create([
'producto_id' => $product->id,
'slug' => 'test-variant-1',
'nombre' => 'Test Variant 1',
'stock' => 10,
'precio' => 99.99,
]);
// 4. Create Attachments
$attachment1 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/image1.png',
'filename' => 'image1.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$attachment2 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/image2.png',
'filename' => 'image2.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
// 5. Associate
$variant->attachments()->attach([$attachment1->id, $attachment2->id]);
// 6. Assert relations
$this->assertCount(2, $variant->attachments);
$this->assertTrue($variant->attachments->contains($attachment1));
$this->assertTrue($variant->attachments->contains($attachment2));
}
public function test_getProductos_listing_image_fallback(): void
{
// 1. Create Tenant
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$headerAttachment = Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::create([
'codigo' => 'acme',
'nombre' => 'Acme Inc.',
'dominio' => 'acme.com',
'primary_color' => '#ffffff',
'secondary_color' => '#ffffff',
'danger_color' => '#ffffff',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#ffffff',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
// 2. Create Product 1 (has 2 attachments itself)
$product1 = Product::create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => 1,
'slug' => 'product-1',
'nombre' => 'Product 1',
'precio' => 10.00,
]);
$p1Attachment1 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/p1_1.png',
'filename' => 'p1_1.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$p1Attachment2 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/p1_2.png',
'filename' => 'p1_2.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$product1->attachments()->attach([$p1Attachment1->id, $p1Attachment2->id]);
// 3. Create Product 2 (no attachments itself, has 2 variants: first variant has 2 attachments, second has 1)
$product2 = Product::create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => 1,
'slug' => 'product-2',
'nombre' => 'Product 2',
'precio' => 20.00,
]);
$v1 = ProductVariant::create([
'producto_id' => $product2->id,
'slug' => 'p2-v1',
'nombre' => 'P2 V1',
'stock' => 10,
'precio' => 20.00,
]);
$v2 = ProductVariant::create([
'producto_id' => $product2->id,
'slug' => 'p2-v2',
'nombre' => 'P2 V2',
'stock' => 5,
'precio' => 20.00,
]);
$v1Attachment1 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/v1_1.png',
'filename' => 'v1_1.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$v1Attachment2 = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/v1_2.png',
'filename' => 'v1_2.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$v1->attachments()->attach([$v1Attachment1->id, $v1Attachment2->id]);
$v2Attachment = Attachment::create([
'key' => (string) Str::uuid(),
'path' => 'attachments/v2_1.png',
'filename' => 'v2_1.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'mime_type' => 'image/png',
]);
$v2->attachments()->attach([$v2Attachment->id]);
// 4. Create Product 3 (no attachments, no variants)
$product3 = Product::create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => 1,
'slug' => 'product-3',
'nombre' => 'Product 3',
'precio' => 30.00,
]);
// Call the listing API
$response = $this->getJson("/api/tenants/{$tenant->codigo}/productos");
$response->assertOk();
// Check response data
// Since we order products by latest() (created_at desc), the order is: Product 3, Product 2, Product 1.
$data = $response->json('data');
$this->assertCount(3, $data);
// Product 1 (index 0) has attachments -> should have exactly its first attachment (p1Attachment1)
$this->assertCount(1, $data[0]['images']);
$this->assertStringContainsString($p1Attachment1->path, $data[0]['images'][0]);
// Product 2 (index 1) has no attachments, falls back to first variant (v1) first attachment (v1Attachment1) -> should have exactly 1 image
$this->assertCount(1, $data[1]['images']);
$this->assertStringContainsString($v1Attachment1->path, $data[1]['images'][0]);
// Product 3 (index 2) has no attachments, no variants -> should have empty images
$this->assertEmpty($data[2]['images']);
}
}

View File

@@ -7,9 +7,10 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\Purchase\Models\Purchase;
@@ -177,17 +178,17 @@ class TelepagosWebhookTest extends TestCase
$this->assertDatabaseHas('compra_items', [
'compra_id' => $matchingPurchase->id,
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'source_catalog_item_id' => $variant->catalog_item_id,
'source_variant_id' => $variant->id,
'cantidad' => 1,
'total' => 50,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 9,
'stock_reservado' => 2,
'cantidad_vendida' => 1,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 9,
'reserved_stock' => 2,
'sold_units' => 1,
]);
$this->assertDatabaseMissing('compra_items', [
@@ -244,12 +245,11 @@ class TelepagosWebhookTest extends TestCase
'id' => $purchase->id,
'status' => Purchase::STATUS_PAID,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 3,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 3,
]);
}
@@ -266,7 +266,8 @@ class TelepagosWebhookTest extends TestCase
'status' => 'active',
]);
$cart->addItem(ProductVariant::class, $variantId, $quantity);
$variant = Variant::query()->findOrFail($variantId);
$cart->addItem($variant->catalog_item_id, $variant->id, $quantity);
/** @var CheckoutService $checkoutService */
$checkoutService = app(CheckoutService::class);
@@ -317,30 +318,27 @@ class TelepagosWebhookTest extends TestCase
string $price,
string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant {
): Variant {
$category = Category::query()->create([
'tenant_code' => $tenantCode,
'nombre' => "{$slugPrefix} category {$tenantCode}",
]);
$product = Product::query()->create([
'tenant_codigo' => $tenantCode,
'categoria_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
$inventory = Inventory::query()->create(['real_stock' => $stock]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenantCode,
'category_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(),
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
'descripcion' => 'Test product',
'precio' => $price,
'inventory_policy' => $inventoryPolicy,
]);
return ProductVariant::query()->create([
'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock,
'descripcion' => 'Test variant',
'precio' => $price,
])->load('product');
return Variant::query()->create([
'catalog_item_id' => $catalogItem->id,
'inventory_id' => $inventory->id,
])->load(['catalogItem', 'inventory']);
}
private function createTenant(string $codigo, string $nombre, string $dominio): Tenant

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Integration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class TenantIntegrationControllerTest extends TestCase
{
use RefreshDatabase;
public function test_store_returns_success_message_without_integration_data(): void
{
$integration = Integration::create([
'integration_code' => 'test_integration',
'name' => 'Test Integration',
'integration_data_schema' => [
'api_key' => 'required|string',
],
]);
$this->mock(TenantIntegrationService::class, function ($mock) use ($integration) {
$mock->shouldReceive('updateOrCreateIntegration')
->once()
->with(
'test-tenant',
Mockery::on(fn (Integration $argument) => $argument->is($integration)),
['api_key' => 'secret']
)
->andReturn(new TenantIntegration());
});
$this->postJson('/api/test-tenant/integrations/test_integration', [
'integration_data' => [
'api_key' => 'secret',
],
])->assertOk()
->assertExactJson([
'message' => 'integration configured correctly',
]);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Tests\Feature\Purchase;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class PurchaseCatalogItemTest extends TestCase
{
use RefreshDatabase;
public function test_checkout_persists_catalog_identity_without_polymorphism(): void
{
Storage::fake('s3');
Storage::disk('s3')->buildTemporaryUrlsUsing(
fn (string $path): string => "https://snapshots.test/{$path}",
);
$tenant = $this->createTenant();
$user = User::factory()->create();
$inventory = Inventory::query()->create(['real_stock' => 10]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory->id,
'slug' => 'checkout-item',
'nombre' => 'Checkout item',
'descripcion' => 'Original description',
'precio' => 25,
]);
$productImage = $this->createAttachment('checkout-item');
Storage::disk('s3')->put($productImage->path, 'original-image');
$catalogItem->attachments()->attach($productImage->id, ['orden' => 0]);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem($catalogItem->id, null, 2);
$service = app(CheckoutService::class);
$purchase = $service->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,
'dni' => '12345678',
'telefono' => '123456789',
'nombre_apellido' => 'Test User',
'email' => 'test@example.com',
]);
$service->confirmPurchase($purchase);
$this->assertTrue(Schema::hasColumns('compra_items', [
'source_catalog_item_id',
'source_variant_id',
'image_attachment_id',
'nombre',
'descripcion',
'slug',
'item_nombre',
'variant_attributes',
]));
$this->assertFalse(Schema::hasColumn('compra_items', 'catalog_item_id'));
$this->assertFalse(Schema::hasColumn('compra_items', 'variant_id'));
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type'));
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id'));
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => null,
'image_attachment_id' => $purchase->items()->value('image_attachment_id'),
'nombre' => 'Checkout item',
'descripcion' => 'Original description',
'slug' => 'checkout-item',
'item_nombre' => 'Checkout item',
'cantidad' => 2,
'precio_unitario' => '25.00',
'total' => '50.00',
]);
$purchaseItem = $purchase->items()->with('imageAttachment')->firstOrFail();
$this->assertNotNull($purchaseItem->imageAttachment);
$this->assertNotSame($productImage->id, $purchaseItem->image_attachment_id);
$this->assertSame('original-image', Storage::disk('s3')->get($purchaseItem->imageAttachment->path));
$this->assertStringStartsWith(
"purchase/{$purchase->id}/",
$purchaseItem->imageAttachment->path,
);
$catalogItem->update([
'nombre' => 'Changed catalog item',
'descripcion' => 'Changed description',
]);
$catalogItem->delete();
$purchaseItem->refresh();
$this->assertSame('Checkout item', $purchaseItem->nombre);
$this->assertSame('Original description', $purchaseItem->descripcion);
$this->assertDatabaseHas('compra_items', ['id' => $purchaseItem->id]);
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.items.0.source_catalog_item_id', $catalogItem->id)
->assertJsonPath('data.items.0.item_details.nombre', 'Checkout item')
->assertJsonPath('data.items.0.item_details.descripcion', 'Original description')
->assertJsonMissingPath('data.items.0.product')
->assertJsonMissingPath('data.items.0.variant');
$this->assertDatabaseHas('inventories', [
'id' => $inventory->id,
'real_stock' => 8,
'reserved_stock' => 0,
'sold_units' => 2,
]);
}
private function createTenant(): Tenant
{
$headerLogo = $this->createAttachment('purchase-header');
$footerLogo = $this->createAttachment('purchase-footer');
return Tenant::query()->create([
'codigo' => 'purchase-catalog',
'nombre' => 'Purchase Catalog',
'dominio' => 'purchase-catalog.local',
'primary_color' => '#000000',
'secondary_color' => '#000000',
'danger_color' => '#000000',
'success_color' => '#000000',
'header_bg_color' => '#000000',
'footer_bg_color' => '#000000',
'header_logo_id' => $headerLogo->id,
'footer_logo_id' => $footerLogo->id,
]);
}
private function createAttachment(string $name): Attachment
{
return Attachment::query()->create([
'path' => "test/{$name}.png",
'filename' => "{$name}.png",
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
'extension' => 'png',
'size' => 14,
]);
}
}

View File

@@ -7,9 +7,10 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -32,27 +33,25 @@ class StorePurchaseTest extends TestCase
'nombre' => 'Test Category',
]);
$product = Product::query()->create([
'tenant_codigo' => 'sonder',
'categoria_id' => $category->id,
$inventory = Inventory::query()->create(['real_stock' => 10]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => 'sonder',
'category_id' => $category->id,
'slug' => 'test-product',
'nombre' => 'Test Product',
'descripcion' => 'Test',
'precio' => '50.00',
]);
$variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'slug' => 'test-variant',
'nombre' => 'Test Variant',
'stock' => 10,
'precio' => '50.00',
$variant = Variant::query()->create([
'catalog_item_id' => $catalogItem->id,
'inventory_id' => $inventory->id,
]);
$cartResponse = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $catalogItem->id,
'variant_id' => $variant->id,
'cantidad' => 2,
])
->assertOk();
@@ -112,14 +111,14 @@ class StorePurchaseTest extends TestCase
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $cartId,
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'catalog_item_id' => $catalogItem->id,
'variant_id' => $variant->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 10,
'stock_reservado' => 2,
$this->assertDatabaseHas('inventories', [
'id' => $inventory->id,
'real_stock' => 10,
'reserved_stock' => 2,
]);
}
@@ -133,8 +132,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 2,
])
->assertOk()
@@ -187,9 +186,9 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
->assertJsonPath('data.items.0.line_total', '100.00')
->assertJsonPath('data.items.0.product.id', $variant->product->id)
->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre)
->assertJsonPath('data.items.0.product.slug', $variant->product->slug)
->assertJsonPath('data.items.0.product.id', $variant->catalogItem->id)
->assertJsonPath('data.items.0.product.nombre', $variant->catalogItem->nombre)
->assertJsonPath('data.items.0.product.slug', $variant->catalogItem->slug)
->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.items.0.variant.id', $variant->id)
->assertJsonPath('data.items.0.variant.attributes', [])
@@ -245,11 +244,11 @@ class StorePurchaseTest extends TestCase
$checkoutService->confirmPurchase($purchase);
$purchase->refresh()->markAsPaid();
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'stock_real' => 8,
'stock_reservado' => 0,
'cantidad_vendida' => 2,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 8,
'reserved_stock' => 0,
'sold_units' => 2,
]);
$this->assertSoftDeleted('carritos', [
@@ -265,10 +264,10 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
->assertJsonPath('data.items.0.line_total', '100.00')
->assertJsonPath('data.items.0.product.id', $variant->product->id)
->assertJsonPath('data.items.0.product.imagen', null)
->assertJsonPath('data.items.0.variant.id', $variant->id)
->assertJsonPath('data.items.0.variant.attributes', [])
->assertJsonPath('data.items.0.source_catalog_item_id', $variant->catalog_item_id)
->assertJsonPath('data.items.0.source_variant_id', $variant->id)
->assertJsonPath('data.items.0.item_details.imagen', null)
->assertJsonPath('data.items.0.item_details.attributes', [])
->assertJsonPath('data.subtotal', '100.00')
->assertJsonPath('data.total', '100.00');
}
@@ -283,8 +282,13 @@ class StorePurchaseTest extends TestCase
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->items()->create([
'buyable_type' => ProductVariant::class,
'buyable_id' => $variant->id,
'source_catalog_item_id' => $variant->catalog_item_id,
'source_variant_id' => $variant->id,
'nombre' => $variant->catalogItem->nombre,
'descripcion' => $variant->catalogItem->descripcion,
'slug' => $variant->catalogItem->slug,
'item_nombre' => $variant->getName(),
'variant_attributes' => [],
'cantidad' => 1,
'precio_unitario' => '50.00',
'discount_total' => null,
@@ -330,8 +334,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($owner, 'sanctum')
->postJson('/api/tenants/sonder/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 1,
])
->assertOk()
@@ -357,8 +361,8 @@ class StorePurchaseTest extends TestCase
$cartId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/globex/cart/items', [
'buyable_type' => 'variant',
'buyable_id' => $variant->id,
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 1,
])
->assertOk()
@@ -438,12 +442,11 @@ class StorePurchaseTest extends TestCase
$checkoutService->confirmPurchase($purchase);
$checkoutService->confirmPurchase($purchase);
$this->assertDatabaseHas('productos_variantes', [
'id' => $variant->id,
'inventory_policy' => InventoryPolicy::Unlimited->value,
'stock_real' => 0,
'stock_reservado' => 0,
'cantidad_vendida' => 25,
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 25,
]);
$this->assertDatabaseCount('compra_items', 1);
}
@@ -454,7 +457,7 @@ class StorePurchaseTest extends TestCase
string $price,
string $slugPrefix = 'shirt',
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant {
): Variant {
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant) {
$this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com");
@@ -465,30 +468,27 @@ class StorePurchaseTest extends TestCase
'nombre' => "{$slugPrefix} category {$tenantCode}",
]);
$product = Product::query()->create([
'tenant_codigo' => $tenantCode,
'categoria_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(),
$inventory = Inventory::query()->create(['real_stock' => $stock]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenantCode,
'category_id' => $category->id,
'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(),
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
'descripcion' => 'Test product',
'precio' => $price,
'inventory_policy' => $inventoryPolicy,
]);
return ProductVariant::query()->create([
'producto_id' => $product->id,
'inventory_policy' => $inventoryPolicy->value,
'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(),
'nombre' => ucfirst($slugPrefix).' Variant',
'stock' => $stock,
'descripcion' => 'Test variant',
'precio' => $price,
])->load('product');
return Variant::query()->create([
'catalog_item_id' => $catalogItem->id,
'inventory_id' => $inventory->id,
])->load(['catalogItem', 'inventory']);
}
protected function createCheckoutPurchase(
User $user,
string $tenantCode,
ProductVariant $variant,
Variant $variant,
int $quantity,
): Purchase {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
@@ -498,7 +498,7 @@ class StorePurchaseTest extends TestCase
'status' => 'active',
]);
$cart->addItem(ProductVariant::class, $variant->id, $quantity);
$cart->addItem($variant->catalog_item_id, $variant->id, $quantity);
return app(CheckoutService::class)->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,

View File

@@ -4,8 +4,11 @@ namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder;
@@ -16,7 +19,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_seeds_event_attributes_and_bundles(): void
public function test_it_seeds_event_items_for_the_new_catalog(): void
{
$headerLogo = Attachment::query()->create([
'path' => 'tests/header.png',
@@ -45,6 +48,10 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
'footer_logo_id' => $footerLogo->id,
]);
$this->seed([
AttributeSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
]);
$this->seed([
AttributeSeeder::class,
FiestaFutbolInfantilProductSeeder::class,
@@ -59,37 +66,77 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
Attribute::query()->where('tenant_codigo', $tenant->codigo)->pluck('codigo')->all()
);
$allDaysBundle = Bundle::query()
->where('tenant_codigo', $tenant->codigo)
->where('nombre', 'Entrada General - Todos los días')
->with('items.variant.product', 'items.variant.definitions')
$generalAdmission = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('slug', 'entrada-general')
->with('variants.definitions')
->sole();
$this->assertSame('40000.00', $allDaysBundle->precio);
$this->assertCount(4, $allDaysBundle->items);
$this->assertNull($generalAdmission->inventory_id);
$this->assertCount(4, $generalAdmission->variants);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$allDaysBundle->items->map(fn ($item) => $item->variant->definitions->sole()->value)->all()
$generalAdmission->variants->map(fn ($variant) => $variant->definitions->sole()->value)->all()
);
$this->assertTrue($allDaysBundle->items->every(
fn ($item) => $item->cantidad === 1 && $item->variant->product->slug === 'entrada-general'
));
$foodBundle = Bundle::query()
->where('tenant_codigo', $tenant->codigo)
->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas')
->with('items.variant.product')
$allDaysItem = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Entrada General - Todos los días')
->with('bundleComponents.variant.definitions')
->sole();
$this->assertSame('24000.00', $foodBundle->precio);
$this->assertSame(CatalogItemType::Bundle, $allDaysItem->type);
$this->assertSame('40000.00', $allDaysItem->precio);
$this->assertNull($allDaysItem->inventory_id);
$this->assertFalse($allDaysItem->has_tickets);
$this->assertCount(4, $allDaysItem->bundleComponents);
$this->assertEqualsCanonicalizing(
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$allDaysItem->bundleComponents
->map(fn ($component) => $component->variant->definitions->sole()->value)
->all()
);
$foodCombo = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', 'Combo 2 Panchos + 2 Hamburguesas')
->with('bundleComponents.catalogItem')
->sole();
$this->assertSame(CatalogItemType::Bundle, $foodCombo->type);
$this->assertSame('24000.00', $foodCombo->precio);
$this->assertNull($foodCombo->inventory_id);
$this->assertSame(
[
'pancho' => 2,
'hamburguesa-papa-frita' => 2,
'pancho' => 2,
],
$foodBundle->items->mapWithKeys(
fn ($item) => [$item->variant->product->slug => $item->cantidad]
)->all()
$foodCombo->bundleComponents
->mapWithKeys(fn ($component): array => [
$component->catalogItem->slug => $component->quantity,
])
->sortKeys()
->all()
);
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
$this->assertSame(10, Inventory::query()->count());
$this->assertSame(
[
'Entradas' => ['entrada-general', 'entrada-general-todos-los-dias'],
'Estacionamiento' => ['estacionamiento-auto', 'estacionamiento-moto'],
'Comidas' => ['hamburguesa-papa-frita', 'pancho', 'combo-2-panchos-2-hamburguesas'],
'Bebidas' => ['coca-cola-500ml', 'agua-mineral-1l'],
],
FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->with('featuredItems.catalogItem')
->orderBy('group_order')
->get()
->mapWithKeys(fn (FeaturedGroup $group): array => [
$group->group_name => $group->featuredItems->pluck('catalogItem.slug')->all(),
])
->all()
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Seeders;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AttributeSeeder;
use Database\Seeders\BrandSeeder;
use Database\Seeders\CategorySeeder;
use Database\Seeders\ProductCatalogFromImagesSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ProductCatalogFromImagesSeederTest extends TestCase
{
use RefreshDatabase;
public function test_it_features_every_sonder_product_in_a_single_image_column(): void
{
Storage::fake('s3');
$logo = Attachment::query()->create([
'path' => 'tests/sonder.png',
'filename' => 'sonder.png',
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$tenant = Tenant::query()->create([
'codigo' => 'sonder',
'nombre' => 'Sonder',
'dominio' => 'sonder.localhost',
'primary_color' => '#6376F3',
'secondary_color' => '#A0A0A0',
'danger_color' => '#FF8888',
'success_color' => '#198754',
'header_bg_color' => '#ffffff',
'footer_bg_color' => '#313131',
'header_logo_id' => $logo->id,
'footer_logo_id' => $logo->id,
]);
$this->seed([
AttributeSeeder::class,
CategorySeeder::class,
BrandSeeder::class,
ProductCatalogFromImagesSeeder::class,
]);
$group = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->with('featuredItems.catalogItem')
->sole();
$this->assertSame('Productos', $group->group_name);
$this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout);
$this->assertSame(0, $group->group_order);
$this->assertSame(
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('id')->pluck('slug')->all(),
$group->featuredItems->pluck('catalogItem.slug')->all(),
);
$this->assertCount(10, $group->featuredItems);
}
}

View File

@@ -0,0 +1,189 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Tests\TestCase;
class CatalogModelsTest extends TestCase
{
public function test_attribute_maps_its_values_and_relations(): void
{
$attribute = new Attribute;
$attribute->setRawAttributes([
'is_required' => 1,
'metadata_schema' => '{"swatch":true}',
'type' => FieldType::Select->value,
]);
$this->assertSame('attribute', $attribute->getTable());
$this->assertTrue($attribute->is_required);
$this->assertSame(['swatch' => true], $attribute->metadata_schema);
$this->assertSame(FieldType::Select, $attribute->type);
$this->assertInstanceOf(Tenant::class, $attribute->tenant()->getRelated());
$this->assertInstanceOf(AttributeOption::class, $attribute->options()->getRelated());
}
public function test_catalog_item_is_the_catalog_root(): void
{
$item = new CatalogItem;
$item->setRawAttributes([
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'type' => CatalogItemType::Standard->value,
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => 1,
]);
$this->assertSame('catalog_items', $item->getTable());
$this->assertFalse($item->usesTimestamps());
$this->assertSame(10, $item->category_id);
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertTrue($item->has_tickets);
$this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated());
$this->assertInstanceOf(Category::class, $item->category()->getRelated());
$this->assertInstanceOf(Brand::class, $item->brand()->getRelated());
$this->assertInstanceOf(Inventory::class, $item->inventory()->getRelated());
$this->assertInstanceOf(BundleComponent::class, $item->bundleComponents()->getRelated());
$this->assertInstanceOf(BundleComponent::class, $item->bundleComponentUsages()->getRelated());
$this->assertInstanceOf(Variant::class, $item->variants()->getRelated());
$this->assertInstanceOf(Attribute::class, $item->attributes()->getRelated());
$this->assertInstanceOf(ItemAttribute::class, $item->itemAttributes()->getRelated());
$this->assertInstanceOf(FeaturedItem::class, $item->featuredItems()->getRelated());
$this->assertInstanceOf(Attachment::class, $item->attachments()->getRelated());
$this->assertSame('catalog_items_attachments', $item->attachments()->getTable());
}
public function test_featured_models_map_catalog_relations_and_layout(): void
{
$group = new FeaturedGroup;
$group->setRawAttributes([
'product_layout' => ProductLayout::ColumnWithImage->value,
'group_order' => '2',
]);
$featuredItem = new FeaturedItem;
$featuredItem->setRawAttributes([
'featured_group_id' => '10',
'catalog_item_id' => '20',
'order' => '3',
]);
$this->assertSame('featured_groups', $group->getTable());
$this->assertFalse($group->usesTimestamps());
$this->assertSame(ProductLayout::ColumnWithImage, $group->product_layout);
$this->assertSame(2, $group->group_order);
$this->assertInstanceOf(Tenant::class, $group->tenant()->getRelated());
$this->assertInstanceOf(FeaturedItem::class, $group->featuredItems()->getRelated());
$this->assertSame('featured_items', $featuredItem->getTable());
$this->assertFalse($featuredItem->usesTimestamps());
$this->assertSame(10, $featuredItem->featured_group_id);
$this->assertSame(20, $featuredItem->catalog_item_id);
$this->assertSame(3, $featuredItem->order);
$this->assertInstanceOf(FeaturedGroup::class, $featuredItem->featuredGroup()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $featuredItem->catalogItem()->getRelated());
}
public function test_variant_has_direct_catalog_and_inventory_relations(): void
{
$variant = new Variant;
$variant->setRawAttributes(['catalog_item_id' => '10', 'inventory_id' => '20']);
$this->assertSame('variantes', $variant->getTable());
$this->assertSame(10, $variant->catalog_item_id);
$this->assertSame(20, $variant->inventory_id);
$this->assertInstanceOf(CatalogItem::class, $variant->catalogItem()->getRelated());
$this->assertInstanceOf(Inventory::class, $variant->inventory()->getRelated());
$this->assertInstanceOf(VariantDefinition::class, $variant->definitions()->getRelated());
$this->assertInstanceOf(Attachment::class, $variant->attachments()->getRelated());
$this->assertSame('catalog_items_attachments', $variant->attachments()->getTable());
}
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
{
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);
$inventory->sold_units = '2';
$this->assertSame('inventories', $inventory->getTable());
$this->assertFalse($inventory->usesTimestamps());
$this->assertSame(2, $inventory->sold_units);
$this->assertSame(7, $inventory->availableStock());
$this->assertInstanceOf(CatalogItem::class, $inventory->catalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $inventory->variant()->getRelated());
}
public function test_catalog_item_aggregates_variant_inventory(): void
{
$first = (new Variant)->setRelation('inventory', $this->trackedInventory(10, 3));
$second = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 1));
$item = new CatalogItem;
$item->inventory_policy = InventoryPolicy::Tracked;
$item->setRelation('variants', new EloquentCollection([$first, $second]));
$item->setRelation('inventory', null);
$this->assertSame(11, $item->availableStock());
$this->assertTrue($item->isAvailable());
}
public function test_catalog_item_prioritizes_its_inventory_over_variants(): void
{
$item = new CatalogItem;
$item->inventory_policy = InventoryPolicy::Tracked;
$item->setRelation('variants', new EloquentCollection([
(new Variant)->setRelation('inventory', $this->trackedInventory(100, 0)),
]));
$item->setRelation('inventory', $this->trackedInventory(8, 2));
$this->assertSame(6, $item->availableStock());
$this->assertTrue($item->isAvailable());
}
public function test_item_attribute_and_variant_definition_use_catalog_keys(): void
{
$itemAttribute = new ItemAttribute;
$definition = new VariantDefinition;
$this->assertSame('item_attributes', $itemAttribute->getTable());
$this->assertInstanceOf(CatalogItem::class, $itemAttribute->catalogItem()->getRelated());
$this->assertInstanceOf(Attribute::class, $itemAttribute->attribute()->getRelated());
$this->assertInstanceOf(VariantDefinition::class, $itemAttribute->variantDefinitions()->getRelated());
$this->assertSame('variant_values', $definition->getTable());
$this->assertInstanceOf(Variant::class, $definition->variant()->getRelated());
$this->assertInstanceOf(ItemAttribute::class, $definition->itemAttribute()->getRelated());
}
private function trackedInventory(int $realStock, int $reservedStock): Inventory
{
$inventory = new Inventory;
$inventory->setRawAttributes([
'real_stock' => $realStock,
'reserved_stock' => $reservedStock,
]);
return $inventory;
}
}

View File

@@ -1,153 +0,0 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class ProductVariantInventoryTest extends TestCase
{
use RefreshDatabase;
private Product $product;
protected function setUp(): void
{
parent::setUp();
$headerAttachment = $this->createAttachment('header.png');
$footerAttachment = $this->createAttachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'inventory-test',
'nombre' => 'Inventory Test',
'dominio' => 'inventory.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#444444',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$category = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Inventory',
]);
$this->product = Product::query()->create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => $category->id,
'slug' => 'inventory-product',
'nombre' => 'Inventory Product',
'precio' => 100,
]);
}
public function test_it_defaults_to_tracked_inventory_with_no_sales(): void
{
$variant = $this->createVariant(5);
$this->assertSame(InventoryPolicy::Tracked, $variant->inventory_policy);
$this->assertSame(5, $variant->availableQuantity());
$this->assertSame(0, $variant->cantidad_vendida);
$this->assertTrue($variant->isAvailableForSale());
}
public function test_tracked_inventory_cannot_reserve_more_than_available_stock(): void
{
$variant = $this->createVariant(5);
$variant->reserveStock(3);
$this->assertSame(2, $variant->fresh()->availableQuantity());
$this->expectException(\InvalidArgumentException::class);
$variant->reserveStock(3);
}
public function test_unlimited_inventory_can_reserve_more_than_real_stock(): void
{
$variant = $this->createVariant(0, InventoryPolicy::Unlimited);
$variant->reserveStock(50);
$variant->refresh();
$this->assertNull($variant->availableQuantity());
$this->assertSame(50, $variant->stock_reservado);
$this->assertTrue($variant->isAvailableForSale());
}
public function test_buying_tracked_inventory_consumes_stock_and_records_the_sale(): void
{
$variant = $this->createVariant(10);
$variant->reserveStock(4);
$variant->buy(3);
$variant->refresh();
$this->assertSame(7, $variant->stock_real);
$this->assertSame(1, $variant->stock_reservado);
$this->assertSame(3, $variant->cantidad_vendida);
}
public function test_buying_unlimited_inventory_preserves_real_stock_and_records_the_sale(): void
{
$variant = $this->createVariant(0, InventoryPolicy::Unlimited);
$variant->reserveStock(4);
$variant->buy(3);
$variant->refresh();
$this->assertSame(0, $variant->stock_real);
$this->assertSame(1, $variant->stock_reservado);
$this->assertSame(3, $variant->cantidad_vendida);
}
public function test_buy_requires_enough_reserved_stock(): void
{
$variant = $this->createVariant(10);
$variant->reserveStock(1);
$this->expectException(\InvalidArgumentException::class);
$variant->buy(2);
}
public function test_inventory_policy_cannot_change_after_creation(): void
{
$variant = $this->createVariant(10);
$variant->inventory_policy = InventoryPolicy::Unlimited;
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('La politica de inventario no puede modificarse.');
$variant->save();
}
private function createVariant(
int $stock,
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
): ProductVariant {
return ProductVariant::query()->create([
'producto_id' => $this->product->id,
'stock' => $stock,
'inventory_policy' => $inventoryPolicy->value,
]);
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tests/'.$filename,
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}