test: add feature tests for product and variant management with attribute associations

This commit is contained in:
2026-07-01 09:51:24 -03:00
parent e78e64ed2e
commit bbfe632926

View File

@@ -5,6 +5,7 @@ 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\Brand;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Models\ProductVariant;
@@ -19,6 +20,8 @@ class ProductControllerTest extends TestCase
private Tenant $tenant;
private Brand $brand;
private Attribute $sizeAttr;
private Attribute $colorAttr;
@@ -31,6 +34,11 @@ class ProductControllerTest extends TestCase
$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,
@@ -69,6 +77,7 @@ class ProductControllerTest extends TestCase
{
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'remera-sport',
'nombre' => 'Remera Sport',
'descripcion' => 'Remera para hacer deportes',
@@ -109,44 +118,43 @@ class ProductControllerTest extends TestCase
'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 = [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'nombre' => 'Remera Sport S Azul',
'stock' => 10,
'precio' => 15000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
],
[
'attribute_id' => $this->colorAttr->id,
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Azul',
],
],
];
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
$variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
$variantResponse->assertCreated();
$this->assertDatabaseHas('productos_variantes', [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'stock' => 10,
'precio' => 15000.00,
]);
$variantS = ProductVariant::where('slug', 'remera-sport-s-azul')->firstOrFail();
$variantS = ProductVariant::where('producto_id', $product->id)->where('stock', 10)->firstOrFail();
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'attribute_id' => $this->sizeAttr->id,
'products_attribute_id' => $productAttributes['size']->id,
'value' => 'S',
]);
$this->assertDatabaseHas('productos_variantes_values', [
'producto_variante_id' => $variantS->id,
'attribute_id' => $this->colorAttr->id,
'products_attribute_id' => $productAttributes['color']->id,
'value' => 'Azul',
]);
}
@@ -157,6 +165,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -167,6 +176,7 @@ class ProductControllerTest extends TestCase
// 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,
@@ -202,35 +212,33 @@ class ProductControllerTest extends TestCase
$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 = [
'producto_id' => $product->id,
'slug' => 'pantalon-cargo-38-rojo',
'nombre' => 'Pantalon Cargo 38 Rojo',
'stock' => 5,
'precio' => 20000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
],
[
'attribute_id' => $this->colorAttr->id, // not associated!
'products_attribute_id' => 99999, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload);
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
}
public function test_it_rejects_variant_update_with_attributes_not_associated_with_product(): void
@@ -239,46 +247,41 @@ class ProductControllerTest extends TestCase
$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([
'slug' => 'pantalon-cargo-38',
'nombre' => 'Pantalon Cargo 38',
'stock' => 5,
'precio' => 20000.00,
]);
$variant->definitions()->create([
'attribute_id' => $this->sizeAttr->id,
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
]);
// Attempt to update variant with colorAttr (which is not associated with the product)
$variantPayload = [
'producto_id' => $product->id,
'slug' => 'pantalon-cargo-38-rojo',
'nombre' => 'Pantalon Cargo 38 Rojo',
'stock' => 5,
'precio' => 20000.00,
'definitions' => [
[
'attribute_id' => $this->sizeAttr->id,
'products_attribute_id' => $sizeProductAttr->id,
'value' => '38',
],
[
'attribute_id' => $this->colorAttr->id, // not associated!
'products_attribute_id' => 99999, // not associated!
'value' => 'Rojo',
],
],
];
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/product-variants/{$variant->id}", $variantPayload);
$response = $this->putJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", $variantPayload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.1.attribute_id']);
$response->assertJsonValidationErrors(['definitions.1.products_attribute_id']);
}
public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void
@@ -286,6 +289,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -300,6 +304,7 @@ class ProductControllerTest extends TestCase
$payload = [
'categoria_id' => 1,
'brand_id' => $this->brand->id,
'slug' => 'short-running',
'nombre' => 'Short Running Updated',
'precio' => 9000.00,
@@ -329,29 +334,26 @@ class ProductControllerTest extends TestCase
$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 = [
'producto_id' => $product->id,
'slug' => 'remera-sport-s-azul',
'nombre' => 'Remera Sport S Azul',
'stock' => 10,
'precio' => 15000.00,
'definitions' => [
[
'attribute_id' => 99999, // Non-existent ID
'products_attribute_id' => 99999, // Non-existent ID
'value' => 'S',
],
],
];
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $payload);
$response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants", $payload);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['definitions.0.attribute_id']);
$response->assertJsonValidationErrors(['definitions.0.products_attribute_id']);
}
public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void
@@ -371,24 +373,23 @@ class ProductControllerTest extends TestCase
$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([
'slug' => 'test-prod-validation-g',
'nombre' => 'Test Prod Validation G',
'stock' => 5,
'precio' => 100.00,
'definitions' => [
[
'attribute_id' => $selectAttr->id,
'products_attribute_id' => $productAttr->id,
'value' => 'G', // Invalid value
],
],
@@ -400,6 +401,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -507,6 +509,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -545,6 +548,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -552,6 +556,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -569,6 +574,7 @@ class ProductControllerTest extends TestCase
$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,
@@ -612,6 +618,108 @@ class ProductControllerTest extends TestCase
];
}
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);
// 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,
]);
$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());
$this->assertEquals(5, $product->variants()->first()->stock);
}
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,
'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
$this->assertEquals(1, $product->variants()->count());
$defaultVariant = $product->variants()->first();
$this->assertEquals(0, $defaultVariant->stock);
$this->assertEquals(0, $defaultVariant->definitions()->count());
}
private function createAttachment(string $path): Attachment
{
return Attachment::create([