From 46685a7141ff98da42343dcb7e1369444d7a9281 Mon Sep 17 00:00:00 2001 From: Nahuel Coronel Date: Wed, 1 Jul 2026 01:05:31 +0000 Subject: [PATCH] feat: enhance product detail retrieval to support variant selection and fallback image handling --- .../Catalog/Controllers/ProductController.php | 7 +- .../Controllers/ProductVariantController.php | 6 +- .../Resources/ProductVariantResource.php | 21 ++- .../Catalog/Services/ProductService.php | 24 ++- .../Feature/Catalog/ProductControllerTest.php | 168 +++++++++++++++--- 5 files changed, 191 insertions(+), 35 deletions(-) diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index a1afd51..b1a1c9f 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -10,6 +10,7 @@ use App\Domains\Catalog\Services\ProductService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -29,10 +30,12 @@ class ProductController extends Controller return ProductResource::make($product)->response()->setStatusCode(201); } - public function show(Tenant $tenant, Product $producto, ProductService $productService): ProductResource + public function show(Request $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource { $producto = $this->resolveScopedProduct($tenant, $producto); - $producto = $productService->getProductDetail($tenant, $producto); + $variantId = $request->query('variant_id'); + $variantId = is_numeric($variantId) ? (int) $variantId : null; + $producto = $productService->getProductDetail($tenant, $producto, $variantId); return ProductResource::make($producto); } diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php index eeb4b33..88b36db 100644 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ b/app/Domains/Catalog/Controllers/ProductVariantController.php @@ -41,7 +41,11 @@ class ProductVariantController extends Controller $producto = $this->resolveScopedProduct($tenant, $producto); $productVariant = $this->resolveScopedVariant($producto, $productVariant); - return ProductVariantResource::make($productVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments'])); + return ProductVariantResource::make($productVariant->load([ + 'attachments' => fn ($query) => $query->orderBy('attachments.id'), + 'definitions.productAttribute.attribute.options', + 'product.attachments' => fn ($query) => $query->orderBy('attachments.id'), + ])); } public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php index e069a64..d9e5e84 100644 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantResource.php @@ -2,11 +2,12 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Catalog\Models\ProductVariant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Catalog\Models\ProductVariant + * @mixin ProductVariant */ class ProductVariantResource extends JsonResource { @@ -35,13 +36,19 @@ class ProductVariantResource extends JsonResource ->values(); } - // Fallback: use product-level attachments when the variant has none - $this->loadMissing('product.attachments'); + if ($this->relationLoaded('fallbackAttachments')) { + return $this->fallbackAttachments + ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ->values(); + } - return $this->product?->attachments - ?->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) - ?->values() - ?? collect(); + if ($this->relationLoaded('product') && $this->product?->relationLoaded('attachments')) { + return $this->product->attachments + ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ->values(); + } + + return collect(); }), ]; } diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 27729f3..933d700 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -10,6 +10,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ProductService { @@ -281,16 +282,35 @@ class ProductService return $products; } - public function getProductDetail(Tenant $tenant, Product $product): Product + public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product { $product->load([ - 'attachments', + 'attachments' => fn ($query) => $query->orderBy('attachments.id'), + 'attributes.options', 'brand', 'category', 'variants' => fn ($query) => $query->orderBy('id'), 'variants.definitions.productAttribute.attribute.options', ]); + $selectedVariant = $variantId !== null + ? $product->variants->firstWhere('id', $variantId) + : $product->variants->first(fn (ProductVariant $variant) => $variant->stock > 0); + + if ($variantId !== null && $selectedVariant === null) { + throw new NotFoundHttpException('Product variant not found for product.'); + } + + $selectedVariant ??= $product->variants->first(); + + if ($selectedVariant !== null) { + $selectedVariant->load([ + 'attachments' => fn ($query) => $query->orderBy('attachments.id'), + ]); + $selectedVariant->setRelation('fallbackAttachments', $product->attachments); + $product->setSelectedVariant($selectedVariant); + } + return $product; } } diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 5fb8ee9..460a68a 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\Attribute; 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; @@ -394,9 +395,8 @@ class ProductControllerTest extends TestCase ]); } - public function test_it_returns_product_detail_with_variant_mapping(): void + public function test_it_returns_product_detail_with_variant_mapping_and_default_selected_variant(): void { - // 1. Create a product $product = Product::create([ 'tenant_codigo' => $this->tenant->codigo, 'categoria_id' => 1, @@ -404,26 +404,17 @@ class ProductControllerTest extends TestCase 'nombre' => 'Test Product Show', 'precio' => 100.00, ]); + $productAttributes = $this->syncVariantAttributes($product); - // Associate attributes to product - $product->attributes()->sync([$this->sizeAttr->id, $this->colorAttr->id]); - $sizeProductAttribute = $product->productAttributes() - ->where('attribute_id', $this->sizeAttr->id) - ->firstOrFail(); - $colorProductAttribute = $product->productAttributes() - ->where('attribute_id', $this->colorAttr->id) - ->firstOrFail(); - - // 2. Create variants $variant = $product->createVariant([ - 'stock' => 10, + 'stock' => 0, 'definitions' => [ [ - 'products_attribute_id' => $sizeProductAttribute->id, + 'products_attribute_id' => $productAttributes['size']->id, 'value' => 'S', ], [ - 'products_attribute_id' => $colorProductAttribute->id, + 'products_attribute_id' => $productAttributes['color']->id, 'value' => 'Azul', ], ], @@ -433,26 +424,26 @@ class ProductControllerTest extends TestCase 'stock' => 4, 'definitions' => [ [ - 'products_attribute_id' => $sizeProductAttribute->id, + 'products_attribute_id' => $productAttributes['size']->id, 'value' => '38', ], [ - 'products_attribute_id' => $colorProductAttribute->id, + 'products_attribute_id' => $productAttributes['color']->id, 'value' => 'Rojo', ], ], ]); + $variantAttachment = $this->createAttachment('attachments/selected-variant.png'); + $secondVariant->attachments()->attach($variantAttachment->id); - // 3. Request product show detail $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}"); $response->assertOk(); - - // 4. Assert variant mapping structure and data $response->assertJsonStructure([ 'data' => [ 'id', 'nombre', + 'attributes', 'variants_map' => [ '*' => [ 'variant_id', @@ -463,14 +454,18 @@ class ProductControllerTest extends TestCase ], ], ], + 'variant' => [ + 'id', + 'stock', + 'definitions', + 'images', + ], ], ]); - $response->assertJsonMissingPath('data.attributes'); $response->assertJsonMissingPath('data.variants'); - $response->assertJsonMissingPath('data.variant'); $response->assertJsonPath('data.variants_map.0.variant_id', $variant->id); - $response->assertJsonPath('data.variants_map.0.stock', 10); + $response->assertJsonPath('data.variants_map.0.stock', 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); @@ -478,6 +473,133 @@ class ProductControllerTest extends TestCase $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.stock', 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')); + } + + public function test_it_returns_requested_variant_in_product_detail(): void + { + $product = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + '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.stock', 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, + 'slug' => 'test-product-invalid-variant', + 'nombre' => 'Test Product Invalid Variant', + 'precio' => 100.00, + ]); + $otherProduct = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + '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, + '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(), + ]; + } + + 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