Files
shopit-back/tests/Feature/Catalog/BundleCatalogItemTest.php

369 lines
14 KiB
PHP

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