createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $category = Category::query()->create([ 'tenant_code' => 'sonder', 'nombre' => 'Test Category', ]); $product = Product::query()->create([ 'tenant_codigo' => 'sonder', 'categoria_id' => $category->id, 'slug' => 'test-product', 'nombre' => 'Test Product', 'descripcion' => 'Test', 'precio' => '50.00', ]); $variant = ProductVariant::query()->create([ 'producto_id' => $product->id, 'slug' => 'test-variant', 'nombre' => 'Test Variant', 'stock' => 10, 'precio' => '50.00', ]); $cartResponse = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]) ->assertOk(); $cartId = $cartResponse->json('data.id'); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, 'tenant_codigo' => 'sonder', 'user_id' => $user->id, 'status' => 'active', ]); $response = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cartId, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]); $response->assertCreated(); $response->assertJsonPath('data.cart_id', $cartId); $response->assertJsonPath('data.dni', '987654321'); $response->assertJsonPath('data.telefono', '+54 9 341 555-4321'); $response->assertJsonPath('data.nombre_apellido', 'Juan Perez'); $response->assertJsonPath('data.email', 'juan.perez@example.com'); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); $response->assertJsonPath('data.items_source', null); $response->assertJsonPath('data.items', []); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); $purchaseId = $response->json('data.id'); $this->assertDatabaseHas('compras', [ 'id' => $purchaseId, 'cart_id' => $cartId, 'tenant_codigo' => 'sonder', 'user_id' => $user->id, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', 'status' => Purchase::STATUS_CREATED, 'total' => 100, ]); $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $purchaseId, ]); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, 'status' => 'active', 'user_id' => $user->id, ]); $this->assertDatabaseHas('carrito_items', [ 'cart_id' => $cartId, 'buyable_type' => ProductVariant::class, 'buyable_id' => $variant->id, 'cantidad' => 2, ]); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 10, 'stock_reservado' => 2, ]); } public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $cartId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]) ->assertOk() ->json('data.id'); $purchaseId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cartId, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]) ->assertCreated() ->json('data.id'); Purchase::query() ->whereKey($purchaseId) ->update([ 'payment_method' => 'transfer', ]); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchaseId}/complete") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT); $this->assertDatabaseHas('compras', [ 'id' => $purchaseId, 'status' => Purchase::STATUS_PENDING_PAYMENT, 'payment_method' => 'transfer', ]); } public function test_purchase_detail_uses_cart_items_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CREATED) ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') ->assertJsonPath('data.items.0.product.id', $variant->product->id) ->assertJsonPath('data.items.0.product.nombre', $variant->product->nombre) ->assertJsonPath('data.items.0.product.slug', $variant->product->slug) ->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.items.0.variant.id', $variant->id) ->assertJsonPath('data.items.0.variant.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $purchase->update([ 'payment_method' => 'transfer', ]); app(CheckoutService::class)->completePurchase($purchase); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') ->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.items.0.variant.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } public function test_purchase_detail_uses_purchase_items_for_paid_purchase_even_without_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $purchase->update([ 'payment_method' => 'transfer', ]); $checkoutService = app(CheckoutService::class); $purchase = $checkoutService->completePurchase($purchase); $checkoutService->confirmPurchase($purchase); $purchase->refresh()->markAsPaid(); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 8, 'stock_reservado' => 0, 'cantidad_vendida' => 2, ]); $this->assertSoftDeleted('carritos', [ 'id' => $purchase->cart_id, ]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PAID) ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') ->assertJsonPath('data.items.0.line_total', '100.00') ->assertJsonPath('data.items.0.product.id', $variant->product->id) ->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.items.0.variant.id', $variant->id) ->assertJsonPath('data.items.0.variant.attributes', []) ->assertJsonPath('data.subtotal', '100.00') ->assertJsonPath('data.total', '100.00'); } public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $purchase->items()->create([ 'buyable_type' => ProductVariant::class, 'buyable_id' => $variant->id, 'cantidad' => 1, 'precio_unitario' => '50.00', 'discount_total' => null, 'tax_total' => null, 'total' => '50.00', ]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 1) ->assertJsonPath('data.items.0.line_total', '50.00') ->assertJsonPath('data.subtotal', '50.00') ->assertJsonPath('data.total', '50.00'); } public function test_purchase_index_returns_empty_items_without_loaded_relations(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $this->createCheckoutPurchase($user, 'sonder', $variant, 2); $this->actingAs($user, 'sanctum') ->getJson('/api/tenants/sonder/compras?status=created') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.items_source', null) ->assertJsonPath('data.0.items', []) ->assertJsonPath('data.0.total', '100.00'); } public function test_it_rejects_a_cart_from_another_user(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $owner = User::factory()->create(); $attacker = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $cartId = $this->actingAs($owner, 'sanctum') ->postJson('/api/tenants/sonder/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 1, ]) ->assertOk() ->json('data.id'); $this->actingAs($attacker, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cartId, 'dni' => '12345678', 'telefono' => '+54 9 341 555-1111', 'nombre_apellido' => 'Intruso', 'email' => 'intruso@example.com', ]) ->assertNotFound(); } public function test_it_rejects_a_cart_from_another_tenant(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('globex', 'Globex', 'globex.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('globex', 10, '50.00'); $cartId = $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/globex/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 1, ]) ->assertOk() ->json('data.id'); $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cartId, 'dni' => '12345678', 'telefono' => '+54 9 341 555-1111', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]) ->assertNotFound(); } public function test_it_rejects_an_empty_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $cart = Cart::query()->create([ 'tenant_codigo' => 'sonder', 'user_id' => $user->id, 'status' => 'active', ]); $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cart->id, 'dni' => '12345678', 'telefono' => '+54 9 341 555-1111', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['cart_id']); } public function test_it_rejects_a_converted_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $cart = Cart::query()->create([ 'tenant_codigo' => 'sonder', 'user_id' => $user->id, 'status' => 'converted', ]); $this->actingAs($user, 'sanctum') ->postJson('/api/tenants/sonder/compras', [ 'cart_id' => $cart->id, 'dni' => '12345678', 'telefono' => '+54 9 341 555-1111', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]) ->assertUnprocessable() ->assertJsonValidationErrors(['cart_id']); } public function test_it_confirms_unlimited_inventory_without_reducing_real_stock_and_is_idempotent(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant( 'sonder', 0, '50.00', 'unlimited', InventoryPolicy::Unlimited, ); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 25); $purchase->update(['payment_method' => 'transfer']); $checkoutService = app(CheckoutService::class); $purchase = $checkoutService->completePurchase($purchase); $checkoutService->confirmPurchase($purchase); $checkoutService->confirmPurchase($purchase); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'inventory_policy' => InventoryPolicy::Unlimited->value, 'stock_real' => 0, 'stock_reservado' => 0, 'cantidad_vendida' => 25, ]); $this->assertDatabaseCount('compra_items', 1); } protected function createVariantForTenant( string $tenantCode, int $stock, string $price, string $slugPrefix = 'shirt', InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, ): ProductVariant { $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); if (! $tenant) { $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); } $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); $product = Product::query()->create([ 'tenant_codigo' => $tenantCode, 'categoria_id' => $category->id, 'slug' => "{$slugPrefix}-{$tenantCode}-".Product::query()->count(), 'nombre' => ucfirst($slugPrefix)." {$tenantCode}", 'descripcion' => 'Test product', 'precio' => $price, ]); return ProductVariant::query()->create([ 'producto_id' => $product->id, 'inventory_policy' => $inventoryPolicy->value, 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'nombre' => ucfirst($slugPrefix).' Variant', 'stock' => $stock, 'descripcion' => 'Test variant', 'precio' => $price, ])->load('product'); } protected function createCheckoutPurchase( User $user, string $tenantCode, ProductVariant $variant, int $quantity, ): Purchase { $tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); $cart = Cart::query()->create([ 'tenant_codigo' => $tenantCode, 'user_id' => $user->id, 'status' => 'active', ]); $cart->addItem(ProductVariant::class, $variant->id, $quantity); return app(CheckoutService::class)->startCheckout($tenant, $user->id, [ 'cart_id' => $cart->id, 'dni' => '987654321', 'telefono' => '+54 9 341 555-4321', 'nombre_apellido' => 'Juan Perez', 'email' => 'juan.perez@example.com', ]); } protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) Str::uuid(); $ftrKey = (string) Str::uuid(); $headerAttachment = Attachment::create([ 'key' => $hdrKey, 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); $footerAttachment = Attachment::create([ 'key' => $ftrKey, 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); return Tenant::create([ 'codigo' => $codigo, 'nombre' => $nombre, 'dominio' => $dominio, 'primary_color' => '#111111', 'secondary_color' => '#222222', 'danger_color' => '#333333', 'success_color' => '#28a745', 'header_bg_color' => '#444444', 'footer_bg_color' => '#444444', 'header_logo_id' => $headerAttachment->id, 'footer_logo_id' => $footerAttachment->id, ]); } }