From b5b57a753c7918d51ea6dc32e11e32b74b002439 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 29 Jun 2026 11:10:04 -0300 Subject: [PATCH] feat: implement product and variant management controllers with request validation and feature tests --- .../Controllers/ProductVariantController.php | 2 +- .../Catalog/Requests/StoreProductRequest.php | 24 -- .../Requests/StoreProductVariantRequest.php | 12 + .../Catalog/Requests/UpdateProductRequest.php | 31 -- .../Requests/UpdateProductVariantRequest.php | 12 + .../Feature/Catalog/ProductControllerTest.php | 267 +++++++++--------- 6 files changed, 152 insertions(+), 196 deletions(-) diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php index 4c31702..7b361a5 100644 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ b/app/Domains/Catalog/Controllers/ProductVariantController.php @@ -29,7 +29,7 @@ class ProductVariantController extends Controller public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse { $validated = $request->validated(); - $variant = DB::transaction(function () use ($request, $tenant): ProductVariant { + $variant = DB::transaction(function () use ($request, $tenant, $validated): ProductVariant { $definitions = $validated['definitions'] ?? []; unset($validated['definitions']); diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index 00f1285..5405f93 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -31,30 +31,6 @@ class StoreProductRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], - 'variants' => ['sometimes', 'array'], - 'variants.*.slug' => ['nullable', 'string', 'max:255'], - 'variants.*.nombre' => ['nullable', 'string', 'max:255'], - 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], - 'variants.*.descripcion' => ['nullable', 'string'], - 'variants.*.precio' => ['required', 'numeric', 'min:0'], - 'variants.*.definitions' => [ - 'sometimes', - 'array', - function ($attribute, $value, $fail) { - $attributeIds = array_column($value, 'attribute_id'); - if (count($attributeIds) !== count(array_unique($attributeIds))) { - $fail('The definitions must have distinct attribute ids.'); - } - } - ], - 'variants.*.definitions.*.attribute_id' => [ - 'required', - 'integer', - Rule::exists('attribute', 'id')->where( - fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) - ), - ], - 'variants.*.definitions.*.value' => ['nullable', 'string'], ]; } } diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php index 5560de6..b514ab2 100644 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php @@ -45,6 +45,18 @@ class StoreProductVariantRequest extends FormRequest Rule::exists('attribute', 'id')->where( fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), + function ($attribute, $value, $fail) { + $productId = $this->input('producto_id'); + if ($productId) { + $exists = \Illuminate\Support\Facades\DB::table('products_attributes') + ->where('product_id', $productId) + ->where('attribute_id', $value) + ->exists(); + if (!$exists) { + $fail('The selected attribute is not associated with the product.'); + } + } + }, ], 'definitions.*.value' => ['nullable', 'string'], ]; diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php index c2a0b86..ce189bd 100644 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductRequest.php @@ -40,37 +40,6 @@ class UpdateProductRequest extends FormRequest fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], - 'variants' => ['sometimes', 'array'], - 'variants.*.id' => [ - 'sometimes', - 'integer', - Rule::exists('productos_variantes', 'id')->where( - fn ($query) => $query->where('producto_id', $product?->id) - ), - ], - 'variants.*.slug' => ['nullable', 'string', 'max:255'], - 'variants.*.nombre' => ['nullable', 'string', 'max:255'], - 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], - 'variants.*.descripcion' => ['nullable', 'string'], - 'variants.*.precio' => ['required', 'numeric', 'min:0'], - 'variants.*.definitions' => [ - 'sometimes', - 'array', - function ($attribute, $value, $fail) { - $attributeIds = array_column($value, 'attribute_id'); - if (count($attributeIds) !== count(array_unique($attributeIds))) { - $fail('The definitions must have distinct attribute ids.'); - } - } - ], - 'variants.*.definitions.*.attribute_id' => [ - 'required', - 'integer', - Rule::exists('attribute', 'id')->where( - fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) - ), - ], - 'variants.*.definitions.*.value' => ['nullable', 'string'], ]; } } diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php index d868917..49d99e3 100644 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php @@ -49,6 +49,18 @@ class UpdateProductVariantRequest extends FormRequest Rule::exists('attribute', 'id')->where( fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), + function ($attribute, $value, $fail) { + $productId = $this->input('producto_id'); + if ($productId) { + $exists = \Illuminate\Support\Facades\DB::table('products_attributes') + ->where('product_id', $productId) + ->where('attribute_id', $value) + ->exists(); + if (!$exists) { + $fail('The selected attribute is not associated with the product.'); + } + } + }, ], 'definitions.*.value' => ['nullable', 'string'], ]; diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 9f4bd86..2bab839 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -51,7 +51,7 @@ class ProductControllerTest extends TestCase ]); } - public function test_it_creates_product_with_variants_and_definitions_and_syncs_attributes(): void + public function test_it_creates_product_with_attributes_and_then_creates_variants(): void { $payload = [ 'categoria_id' => 1, @@ -61,40 +61,8 @@ class ProductControllerTest extends TestCase 'precio' => 15000.00, 'attribute_ids' => [ $this->extraAttr->id, - ], - 'variants' => [ - [ - 'slug' => 'remera-sport-s-azul', - 'nombre' => 'Remera Sport S Azul', - 'stock' => 10, - 'precio' => 15000.00, - 'definitions' => [ - [ - 'attribute_id' => $this->sizeAttr->id, - 'value' => 'S', - ], - [ - 'attribute_id' => $this->colorAttr->id, - 'value' => 'Azul', - ], - ], - ], - [ - 'slug' => 'remera-sport-m-azul', - 'nombre' => 'Remera Sport M Azul', - 'stock' => 5, - 'precio' => 16000.00, - 'definitions' => [ - [ - 'attribute_id' => $this->sizeAttr->id, - 'value' => 'M', - ], - [ - 'attribute_id' => $this->colorAttr->id, - 'value' => 'Azul', - ], - ], - ], + $this->sizeAttr->id, + $this->colorAttr->id, ], ]; @@ -104,10 +72,6 @@ class ProductControllerTest extends TestCase // Assert JSON structure $response->assertJsonPath('data.nombre', 'Remera Sport'); - $response->assertJsonCount(2, 'data.variants'); - $response->assertJsonPath('data.variants.0.slug', 'remera-sport-s-azul'); - $response->assertJsonPath('data.variants.0.definitions.0.value', 'S'); - $response->assertJsonPath('data.variants.1.precio', '16000.00'); // Assert Database $this->assertDatabaseHas('productos', [ @@ -131,7 +95,28 @@ class ProductControllerTest extends TestCase 'attribute_id' => $this->colorAttr->id, ]); - $this->assertCount(2, $product->variants); + // 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, + 'value' => 'S', + ], + [ + 'attribute_id' => $this->colorAttr->id, + 'value' => 'Azul', + ], + ], + ]; + + $variantResponse = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload); + $variantResponse->assertCreated(); + $this->assertDatabaseHas('productos_variantes', [ 'producto_id' => $product->id, 'slug' => 'remera-sport-s-azul', @@ -152,9 +137,9 @@ class ProductControllerTest extends TestCase ]); } - public function test_it_updates_product_and_syncs_variants_and_attributes(): void + public function test_it_updates_product_attributes_independently(): void { - // 1. Create a product with 2 variants initially + // 1. Create a product with extraAttr $product = Product::create([ 'tenant_codigo' => $this->tenant->codigo, 'categoria_id' => 1, @@ -165,67 +150,13 @@ class ProductControllerTest extends TestCase $product->attributes()->sync([$this->extraAttr->id]); - $v1 = $product->variants()->create([ - 'slug' => 'pantalon-cargo-38', - 'nombre' => 'Pantalon Cargo 38', - 'stock' => 4, - 'precio' => 20000.00, - ]); - $v1->definitions()->create([ - 'attribute_id' => $this->sizeAttr->id, - 'value' => '38', - ]); - - $v2 = $product->variants()->create([ - 'slug' => 'pantalon-cargo-40', - 'nombre' => 'Pantalon Cargo 40', - 'stock' => 8, - 'precio' => 20000.00, - ]); - $v2->definitions()->create([ - 'attribute_id' => $this->sizeAttr->id, - 'value' => '40', - ]); - - // 2. Perform update payload - // We will: - // - Update v1 (change stock/precio, keep id) - // - Delete v2 (by omitting it) - // - Create a new variant v3 - // - Update attribute_ids (remove extraAttr, add sizeAttr and colorAttr indirectly through variants) + // 2. Perform update payload - change name and update attribute_ids to sizeAttr $payload = [ 'categoria_id' => 1, 'slug' => 'pantalon-cargo-new-slug', 'nombre' => 'Pantalon Cargo V2', 'precio' => 22000.00, - 'attribute_ids' => [], // explicitly remove extraAttr - 'variants' => [ - [ - 'id' => $v1->id, - 'slug' => 'pantalon-cargo-38-updated', - 'nombre' => 'Pantalon Cargo 38 Updated', - 'stock' => 12, - 'precio' => 22000.00, - 'definitions' => [ - [ - 'attribute_id' => $this->sizeAttr->id, - 'value' => '38-updated', - ], - ], - ], - [ - 'slug' => 'pantalon-cargo-42', - 'nombre' => 'Pantalon Cargo 42', - 'stock' => 15, - 'precio' => 22000.00, - 'definitions' => [ - [ - 'attribute_id' => $this->sizeAttr->id, - 'value' => '42', - ], - ], - ], - ], + 'attribute_ids' => [$this->sizeAttr->id], ]; $response = $this->putJson( @@ -237,7 +168,6 @@ class ProductControllerTest extends TestCase // Assert updated values $response->assertJsonPath('data.nombre', 'Pantalon Cargo V2'); - $response->assertJsonCount(2, 'data.variants'); // Check DB state // extraAttr must be detached @@ -245,40 +175,96 @@ class ProductControllerTest extends TestCase 'product_id' => $product->id, 'attribute_id' => $this->extraAttr->id, ]); - // sizeAttr must be attached (as it's used in variant v1 and v3 definitions) + // sizeAttr must be attached $this->assertDatabaseHas('products_attributes', [ 'product_id' => $product->id, 'attribute_id' => $this->sizeAttr->id, ]); + } - // v1 must be updated - $this->assertDatabaseHas('productos_variantes', [ - 'id' => $v1->id, - 'slug' => 'pantalon-cargo-38-updated', - 'stock' => 12, - 'precio' => 22000.00, - ]); - $this->assertDatabaseHas('productos_variantes_values', [ - 'producto_variante_id' => $v1->id, - 'attribute_id' => $this->sizeAttr->id, - 'value' => '38-updated', + 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, + 'slug' => 'pantalon-cargo', + 'nombre' => 'Pantalon Cargo', + 'precio' => 20000.00, ]); + $product->attributes()->sync([$this->sizeAttr->id]); - // v2 must be deleted - $this->assertDatabaseMissing('productos_variantes', [ - 'id' => $v2->id, - ]); - $this->assertDatabaseMissing('productos_variantes_values', [ - 'producto_variante_id' => $v2->id, - ]); - - // v3 must be created - $this->assertDatabaseHas('productos_variantes', [ + // Attempt to create variant with colorAttr (which is not associated with the product) + $variantPayload = [ 'producto_id' => $product->id, - 'slug' => 'pantalon-cargo-42', - 'stock' => 15, - 'precio' => 22000.00, + 'slug' => 'pantalon-cargo-38-rojo', + 'nombre' => 'Pantalon Cargo 38 Rojo', + 'stock' => 5, + 'precio' => 20000.00, + 'definitions' => [ + [ + 'attribute_id' => $this->sizeAttr->id, + 'value' => '38', + ], + [ + 'attribute_id' => $this->colorAttr->id, // not associated! + 'value' => 'Rojo', + ], + ], + ]; + + $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $variantPayload); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['definitions.1.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, + 'slug' => 'pantalon-cargo', + 'nombre' => 'Pantalon Cargo', + 'precio' => 20000.00, ]); + $product->attributes()->sync([$this->sizeAttr->id]); + + $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, + '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, + 'value' => '38', + ], + [ + 'attribute_id' => $this->colorAttr->id, // not associated! + 'value' => 'Rojo', + ], + ], + ]; + + $response = $this->putJson("/api/tenants/{$this->tenant->codigo}/product-variants/{$variant->id}", $variantPayload); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['definitions.1.attribute_id']); } public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void @@ -326,31 +312,32 @@ class ProductControllerTest extends TestCase public function test_it_rejects_variants_with_invalid_attributes(): void { - $payload = [ + $product = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, 'categoria_id' => 1, - 'slug' => 'remera-sport', - 'nombre' => 'Remera Sport', + '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, - 'variants' => [ + 'definitions' => [ [ - 'slug' => 'remera-sport-s-azul', - 'nombre' => 'Remera Sport S Azul', - 'stock' => 10, - 'precio' => 15000.00, - 'definitions' => [ - [ - 'attribute_id' => 99999, // Non-existent ID - 'value' => 'S', - ], - ], + 'attribute_id' => 99999, // Non-existent ID + 'value' => 'S', ], ], ]; - $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload); + $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/product-variants", $payload); $response->assertUnprocessable(); - $response->assertJsonValidationErrors(['variants.0.definitions.0.attribute_id']); + $response->assertJsonValidationErrors(['definitions.0.attribute_id']); } protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant