createTenant('acme', 'Acme', 'acme.com'); $this->getJson('/api/tenants/acme/cart') ->assertOk() ->assertJson([ 'data' => [ 'id' => null, 'tenant_codigo' => 'acme', 'status' => 'active', 'items' => [], 'subtotal' => '0.00', ], ]); } public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void { $variant = $this->createVariantForTenant('acme', 10, '49.90'); $attribute = Attribute::query()->create([ 'tenant_codigo' => 'acme', 'codigo' => 'color', 'nombre' => 'Color', 'type' => 'string', ]); $productAttribute = ProductAttribute::query()->create([ 'product_id' => $variant->producto_id, 'attribute_id' => $attribute->id, ]); ProductVariantDefinition::query()->create([ 'producto_variante_id' => $variant->id, 'products_attribute_id' => $productAttribute->id, 'value' => 'Red', ]); $response = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]); $response ->assertOk() ->assertCookie('guest_token') ->assertJsonPath('data.tenant_codigo', 'acme') ->assertJsonPath('data.items.0.cantidad', 2) ->assertJsonPath('data.items.0.precio_unitario', '49.90') ->assertJsonPath('data.items.0.buyable_type', 'variant') ->assertJsonPath('data.items.0.buyable_id', $variant->id) ->assertJsonPath('data.items.0.product.nombre', 'Shirt acme (Color: Red)') ->assertJsonPath('data.items.0.product.imagen', null) ->assertJsonPath('data.subtotal', '99.80'); $this->assertDatabaseHas('carritos', [ 'tenant_codigo' => 'acme', 'guest_token' => $response->getCookie('guest_token', false)?->getValue(), 'status' => 'active', ]); $this->assertDatabaseHas('carrito_items', [ '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_merges_quantities_when_the_same_guest_adds_the_same_variant_twice(): void { $variant = $this->createVariantForTenant('acme', 12, '25.00'); $firstResponse = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]); $guestToken = $firstResponse->getCookie('guest_token', false)?->getValue(); $response = $this->call( 'POST', '/api/tenants/acme/cart/items', [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode([ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 3, ]) ); $response ->assertOk() ->assertJsonPath('data.items.0.cantidad', 5) ->assertJsonPath('data.subtotal', '125.00'); $this->assertDatabaseCount('carritos', 1); $this->assertDatabaseCount('carrito_items', 1); $this->assertDatabaseHas('carrito_items', [ 'buyable_type' => ProductVariant::class, 'buyable_id' => $variant->id, 'cantidad' => 5, ]); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 12, 'stock_reservado' => 5, ]); } public function test_it_updates_item_quantity_and_adjusts_stock(): void { $variant = $this->createVariantForTenant('acme', 10, '15.00'); $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]); $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); $cartItemId = $createResponse->json('data.items.0.id'); $response = $this->call( 'PATCH', "/api/tenants/acme/cart/items/{$cartItemId}", [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode([ 'cantidad' => 5, ]) ); $response ->assertOk() ->assertJsonPath('data.items.0.cantidad', 5) ->assertJsonPath('data.subtotal', '75.00'); $this->assertDatabaseHas('carrito_items', [ 'buyable_type' => ProductVariant::class, 'buyable_id' => $variant->id, 'cantidad' => 5, ]); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 10, 'stock_reservado' => 5, ]); } public function test_it_removes_an_item_and_restores_stock(): void { $variant = $this->createVariantForTenant('acme', 10, '15.00'); $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 4, ]); $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); $cartItemId = $createResponse->json('data.items.0.id'); $response = $this->call( 'DELETE', "/api/tenants/acme/cart/items/{$cartItemId}", [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'] ); $response ->assertOk() ->assertJsonPath('data.items', []) ->assertJsonPath('data.subtotal', '0.00'); $this->assertDatabaseCount('carrito_items', 0); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 10, 'stock_reservado' => 0, ]); } public function test_authenticated_users_reuse_the_same_cart_per_tenant_and_get_a_new_one_for_another_tenant(): void { $user = User::factory()->create(); $acmeVariantA = $this->createVariantForTenant('acme', 10, '10.00'); $acmeVariantB = $this->createVariantForTenant('acme', 8, '20.00', 'hoodie'); $globexVariant = $this->createVariantForTenant('globex', 6, '30.00'); $this->actingAs($user) ->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $acmeVariantA->id, 'cantidad' => 1, ]) ->assertOk(); $this->actingAs($user) ->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $acmeVariantB->id, 'cantidad' => 2, ]) ->assertOk() ->assertJsonPath('data.subtotal', '50.00'); $this->actingAs($user) ->postJson('/api/tenants/globex/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $globexVariant->id, 'cantidad' => 1, ]) ->assertOk(); $this->assertDatabaseCount('carritos', 2); $this->assertDatabaseHas('carritos', [ 'tenant_codigo' => 'acme', 'user_id' => $user->id, ]); $this->assertDatabaseHas('carritos', [ 'tenant_codigo' => 'globex', 'user_id' => $user->id, ]); } public function test_it_rejects_variants_from_another_tenant(): void { $this->createVariantForTenant('acme', 10, '10.00'); $otherVariant = $this->createVariantForTenant('globex', 10, '20.00'); $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $otherVariant->id, 'cantidad' => 1, ])->assertNotFound(); } public function test_it_returns_not_found_when_the_cart_item_does_not_exist_for_update_or_delete(): void { $variant = $this->createVariantForTenant('acme', 10, '10.00'); $this->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [ 'cantidad' => 2, ])->assertNotFound(); $this->deleteJson("/api/tenants/acme/cart/items/{$variant->id}") ->assertNotFound(); } public function test_it_validates_quantity_and_stock_constraints(): void { $variant = $this->createVariantForTenant('acme', 2, '10.00'); $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 0, ])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']); $response = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 2, ]); $guestToken = $response->getCookie('guest_token', false)?->getValue(); $cartItemId = $response->json('data.items.0.id'); $response1 = $this->call( 'POST', '/api/tenants/acme/cart/items', [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode([ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 1, ]) ); $response1 ->assertUnprocessable() ->assertJsonValidationErrors(['cantidad']); $response2 = $this->call( 'PATCH', "/api/tenants/acme/cart/items/{$cartItemId}", [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode([ 'cantidad' => 3, ]) ); $response2 ->assertUnprocessable() ->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']); } public function test_it_only_accepts_buyable_identity_when_adding_an_item(): void { $variant = $this->createVariantForTenant('acme', 10, '10.00'); $this->postJson('/api/tenants/acme/cart/items', [ 'product_variant_id' => $variant->id, 'cantidad' => 1, ])->assertUnprocessable() ->assertJsonValidationErrors(['buyable_type', 'buyable_id']); $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 1, ])->assertOk(); $cartItemId = $createResponse->json('data.items.0.id'); $this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [ 'cantidad' => 2, 'buyable_type' => 'variant', 'buyable_id' => $variant->id, ])->assertUnprocessable() ->assertJsonValidationErrors(['buyable_type', 'buyable_id']); } public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void { $variant = $this->createVariantForTenant( 'acme', 0, '10.00', 'unlimited', InventoryPolicy::Unlimited, ); $response = $this->postJson('/api/tenants/acme/cart/items', [ 'buyable_type' => 'variant', 'buyable_id' => $variant->id, 'cantidad' => 100, ])->assertOk(); $guestToken = $response->getCookie('guest_token', false)?->getValue(); $cartItemId = $response->json('data.items.0.id'); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 0, 'stock_reservado' => 100, ]); $this->call( 'PATCH', "/api/tenants/acme/cart/items/{$cartItemId}", [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], json_encode(['cantidad' => 150]), )->assertOk(); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 0, 'stock_reservado' => 150, ]); $this->call( 'DELETE', "/api/tenants/acme/cart/items/{$cartItemId}", [], ['guest_token' => $guestToken], [], ['HTTP_Accept' => 'application/json'], )->assertOk(); $this->assertDatabaseHas('productos_variantes', [ 'id' => $variant->id, 'stock_real' => 0, 'stock_reservado' => 0, ]); } 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 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, ]); } }