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> $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, ]); } }