From 492c6c4ac241e287a4948db1e760bf6daf081caa Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 25 Jun 2026 10:24:45 -0300 Subject: [PATCH] feat(cart): implement cart management with add, update, and remove item functionalities --- .../Cart/Controllers/CartController.php | 66 ++++ app/Domains/Cart/Models/Cart.php | 66 ++-- .../Cart/Requests/AddCartItemRequest.php | 24 ++ .../UpdateCartItemQuantityRequest.php | 23 ++ .../Cart/Resources/CartItemResource.php | 45 +++ app/Domains/Cart/Resources/CartResource.php | 40 +++ app/Domains/Cart/Services/CartService.php | 204 ++++++++++++ app/Domains/Cart/routes/api.php | 11 + ...026_06_25_000000_create_carritos_table.php | 40 +++ ...6_25_000010_create_carrito_items_table.php | 38 +++ routes/api.php | 1 + tests/Feature/Cart/CartControllerTest.php | 303 ++++++++++++++++++ 12 files changed, 838 insertions(+), 23 deletions(-) create mode 100644 app/Domains/Cart/Controllers/CartController.php create mode 100644 app/Domains/Cart/Requests/AddCartItemRequest.php create mode 100644 app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php create mode 100644 app/Domains/Cart/Resources/CartItemResource.php create mode 100644 app/Domains/Cart/Resources/CartResource.php create mode 100644 app/Domains/Cart/Services/CartService.php create mode 100644 app/Domains/Cart/routes/api.php create mode 100644 database/migrations/2026_06_25_000000_create_carritos_table.php create mode 100644 database/migrations/2026_06_25_000010_create_carrito_items_table.php create mode 100644 tests/Feature/Cart/CartControllerTest.php diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php new file mode 100644 index 0000000..b33ae61 --- /dev/null +++ b/app/Domains/Cart/Controllers/CartController.php @@ -0,0 +1,66 @@ +cartService->show($tenant, $request)); + } + + public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse + { + $result = $this->cartService->addItem( + $tenant, + $request, + (int) $request->validated('product_variant_id'), + (int) $request->validated('cantidad'), + ); + + $response = CartResource::make($result['cart'])->response(); + + if ($result['guest_token'] !== null) { + $response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token'])); + } + + return $response; + } + + public function updateItemQuantity( + UpdateCartItemQuantityRequest $request, + Tenant $tenant, + ProductVariant $productVariant, + ): CartResource { + return CartResource::make( + $this->cartService->updateItemQuantity( + $tenant, + $request, + $productVariant->getKey(), + (int) $request->validated('cantidad'), + ) + ); + } + + public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource + { + return CartResource::make( + $this->cartService->removeItem($tenant, $request, $productVariant->getKey()) + ); + } +} diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 3c84560..08bc5fa 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -3,6 +3,7 @@ namespace App\Domains\Cart\Models; use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Tenant\Models\Tenant; use App\Models\User; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,8 +12,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; #[Fillable([ + 'tenant_codigo', 'user_id', 'guest_token', 'status', @@ -30,6 +33,14 @@ class Cart extends Model ]; } + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + /** * @return BelongsTo */ @@ -54,11 +65,8 @@ class Cart extends Model ]); } - return DB::transaction(function () use ($productVariantId, $quantity) { - /** @var ProductVariant $variant */ - $variant = ProductVariant::query() - ->lockForUpdate() - ->findOrFail($productVariantId); + return DB::transaction(function () use ($productVariantId, $quantity): CartItem { + $variant = $this->resolveScopedVariant($productVariantId, true); if ($variant->stock < $quantity) { throw ValidationException::withMessages([ @@ -68,18 +76,18 @@ class Cart extends Model /** @var CartItem|null $item */ $item = $this->items() - ->where('producto_variante_id', $productVariantId) + ->where('producto_variante_id', $variant->getKey()) ->lockForUpdate() ->first(); - if ($item) { - $item->cantidad += $quantity; - $item->save(); - } else { + if ($item === null) { $item = $this->items()->create([ - 'producto_variante_id' => $productVariantId, + 'producto_variante_id' => $variant->getKey(), 'cantidad' => $quantity, ]); + } else { + $item->cantidad += $quantity; + $item->save(); } $variant->decrement('stock', $quantity); @@ -96,18 +104,14 @@ class Cart extends Model ]); } - return DB::transaction(function () use ($productVariantId, $quantity) { + return DB::transaction(function () use ($productVariantId, $quantity): CartItem { /** @var CartItem $item */ $item = $this->items() ->where('producto_variante_id', $productVariantId) ->lockForUpdate() ->firstOrFail(); - /** @var ProductVariant $variant */ - $variant = ProductVariant::query() - ->lockForUpdate() - ->findOrFail($productVariantId); - + $variant = $this->resolveScopedVariant($productVariantId, true); $delta = $quantity - $item->cantidad; if ($delta > 0 && $variant->stock < $delta) { @@ -133,20 +137,36 @@ class Cart extends Model public function removeItem(int $productVariantId): void { - DB::transaction(function () use ($productVariantId) { + DB::transaction(function () use ($productVariantId): void { /** @var CartItem $item */ $item = $this->items() ->where('producto_variante_id', $productVariantId) ->lockForUpdate() ->firstOrFail(); - /** @var ProductVariant $variant */ - $variant = ProductVariant::query() - ->lockForUpdate() - ->findOrFail($productVariantId); - + $variant = $this->resolveScopedVariant($productVariantId, true); $variant->increment('stock', $item->cantidad); $item->delete(); }); } + + protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant + { + $query = ProductVariant::query() + ->whereKey($productVariantId) + ->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo)); + + if ($lockForUpdate) { + $query->lockForUpdate(); + } + + /** @var ProductVariant|null $variant */ + $variant = $query->first(); + + if ($variant === null) { + throw new NotFoundHttpException('Product variant not found for tenant.'); + } + + return $variant; + } } diff --git a/app/Domains/Cart/Requests/AddCartItemRequest.php b/app/Domains/Cart/Requests/AddCartItemRequest.php new file mode 100644 index 0000000..9bc0bf6 --- /dev/null +++ b/app/Domains/Cart/Requests/AddCartItemRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'product_variant_id' => ['required', 'integer'], + 'cantidad' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php new file mode 100644 index 0000000..2a98754 --- /dev/null +++ b/app/Domains/Cart/Requests/UpdateCartItemQuantityRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'cantidad' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php new file mode 100644 index 0000000..b1990f9 --- /dev/null +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + $variant = $this->variant; + $product = $variant?->product; + + return [ + 'id' => $this->id, + 'cantidad' => $this->cantidad, + 'precio_unitario' => $this->formatMoney($variant?->precio), + 'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad), + 'product' => $product === null ? null : [ + 'id' => $product->id, + 'nombre' => $product->nombre, + 'slug' => $product->slug, + ], + 'variant' => $variant === null ? null : [ + 'id' => $variant->id, + 'nombre' => $variant->nombre, + 'slug' => $variant->slug, + 'definitions' => ProductVariantDefinitionResource::collection($variant->definitions), + ], + ]; + } + + protected function formatMoney(float|int|string|null $amount): string + { + return number_format((float) ($amount ?? 0), 2, '.', ''); + } +} diff --git a/app/Domains/Cart/Resources/CartResource.php b/app/Domains/Cart/Resources/CartResource.php new file mode 100644 index 0000000..2bd53f4 --- /dev/null +++ b/app/Domains/Cart/Resources/CartResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + $items = $this->resource->relationLoaded('items') + ? $this->resource->getRelation('items') + : collect(); + + $subtotal = $items->reduce( + fn (float $carry, $item): float => $carry + ((float) ($item->variant?->precio ?? 0) * $item->cantidad), + 0.0, + ); + + return [ + 'id' => $this->id, + 'tenant_codigo' => $this->tenant_codigo, + 'status' => $this->status ?? 'active', + 'items' => CartItemResource::collection($items), + 'subtotal' => $this->formatMoney($subtotal), + ]; + } + + protected function formatMoney(float|int|string|null $amount): string + { + return number_format((float) ($amount ?? 0), 2, '.', ''); + } +} diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php new file mode 100644 index 0000000..648d9e9 --- /dev/null +++ b/app/Domains/Cart/Services/CartService.php @@ -0,0 +1,204 @@ +resolveIdentity($request); + + if ($identity === null) { + return $this->makeEmptyCart($tenant); + } + + $cart = $this->findCart($tenant, $identity); + + if ($cart === null) { + return $this->makeEmptyCart($tenant); + } + + return $this->loadCart($cart); + } + + /** + * @return array{cart: Cart, guest_token: ?string} + */ + public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array + { + $resolvedIdentity = $this->resolveIdentity($request, true); + $identity = $resolvedIdentity['identity']; + $cart = $this->findOrCreateCart($tenant, $identity); + $cart->addItem($productVariantId, $quantity); + + return [ + 'cart' => $this->loadCart($cart), + 'guest_token' => $resolvedIdentity['generated_guest_token'], + ]; + } + + public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart + { + $identity = $this->requireIdentity($request); + $cart = $this->findCartOrFail($tenant, $identity); + $cart->updateItem($productVariantId, $quantity); + + return $this->loadCart($cart); + } + + public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart + { + $identity = $this->requireIdentity($request); + $cart = $this->findCartOrFail($tenant, $identity); + $cart->removeItem($productVariantId); + + return $this->loadCart($cart); + } + + public function makeGuestTokenCookie(string $guestToken): Cookie + { + return cookie( + 'guest_token', + $guestToken, + 60 * 24 * 180, + '/', + null, + false, + true, + false, + 'lax', + ); + } + + protected function makeEmptyCart(Tenant $tenant): Cart + { + $cart = new Cart([ + 'tenant_codigo' => $tenant->codigo, + 'status' => 'active', + ]); + + $cart->setRelation('items', collect()); + + return $cart; + } + + protected function loadCart(Cart $cart): Cart + { + return $cart->fresh()->load([ + 'items.variant.product', + 'items.variant.definitions.attribute', + ]); + } + + /** + * @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null + */ + protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array + { + $user = $request->user(); + + if ($user instanceof User) { + return [ + 'identity' => [ + 'user_id' => $user->getKey(), + 'guest_token' => null, + ], + 'generated_guest_token' => null, + ]; + } + + $guestToken = $request->cookie('guest_token'); + + if (is_string($guestToken) && $guestToken !== '') { + return [ + 'identity' => [ + 'user_id' => null, + 'guest_token' => $guestToken, + ], + 'generated_guest_token' => null, + ]; + } + + if (! $generateGuestToken) { + return null; + } + + $generatedGuestToken = (string) Str::uuid(); + + return [ + 'identity' => [ + 'user_id' => null, + 'guest_token' => $generatedGuestToken, + ], + 'generated_guest_token' => $generatedGuestToken, + ]; + } + + /** + * @return array{user_id: ?int, guest_token: ?string} + */ + protected function requireIdentity(Request $request): array + { + $resolvedIdentity = $this->resolveIdentity($request); + + if ($resolvedIdentity === null) { + throw new NotFoundHttpException('Cart not found.'); + } + + return $resolvedIdentity['identity']; + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function findCart(Tenant $tenant, array $identity): ?Cart + { + return Cart::query() + ->where('tenant_codigo', $tenant->codigo) + ->when( + $identity['user_id'] !== null, + fn ($query) => $query->where('user_id', $identity['user_id']), + fn ($query) => $query->where('guest_token', $identity['guest_token']), + ) + ->first(); + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function findCartOrFail(Tenant $tenant, array $identity): Cart + { + $cart = $this->findCart($tenant, $identity); + + if ($cart === null) { + throw new NotFoundHttpException('Cart not found.'); + } + + return $cart; + } + + /** + * @param array{user_id: ?int, guest_token: ?string} $identity + */ + protected function findOrCreateCart(Tenant $tenant, array $identity): Cart + { + $attributes = ['tenant_codigo' => $tenant->codigo]; + + if ($identity['user_id'] !== null) { + $attributes['user_id'] = $identity['user_id']; + } else { + $attributes['guest_token'] = $identity['guest_token']; + } + + return Cart::query()->firstOrCreate($attributes, ['status' => 'active']); + } + +} diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php new file mode 100644 index 0000000..20300ae --- /dev/null +++ b/app/Domains/Cart/routes/api.php @@ -0,0 +1,11 @@ +group(function (): void { + Route::get('cart', [CartController::class, 'show']); + Route::post('cart/items', [CartController::class, 'addItem']); + Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']); + Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']); +}); diff --git a/database/migrations/2026_06_25_000000_create_carritos_table.php b/database/migrations/2026_06_25_000000_create_carritos_table.php new file mode 100644 index 0000000..c054303 --- /dev/null +++ b/database/migrations/2026_06_25_000000_create_carritos_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('tenant_codigo'); + $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnUpdate()->cascadeOnDelete(); + $table->string('guest_token')->nullable(); + $table->string('status')->default('active'); + $table->timestamps(); + + $table->foreign('tenant_codigo') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['tenant_codigo', 'user_id']); + $table->unique(['tenant_codigo', 'guest_token']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carritos'); + } +}; diff --git a/database/migrations/2026_06_25_000010_create_carrito_items_table.php b/database/migrations/2026_06_25_000010_create_carrito_items_table.php new file mode 100644 index 0000000..08771b1 --- /dev/null +++ b/database/migrations/2026_06_25_000010_create_carrito_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('cart_id')->constrained('carritos')->cascadeOnUpdate()->cascadeOnDelete(); + $table->unsignedBigInteger('producto_variante_id'); + $table->unsignedInteger('cantidad'); + $table->timestamps(); + + $table->foreign('producto_variante_id') + ->references('id') + ->on('productos_variantes') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['cart_id', 'producto_variante_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carrito_items'); + } +}; diff --git a/routes/api.php b/routes/api.php index 0f64bbf..38f7355 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,4 +8,5 @@ Route::get('/user', function (Request $request) { })->middleware('auth:sanctum'); require __DIR__.'/../app/Domains/Catalog/routes/api.php'; +require __DIR__.'/../app/Domains/Cart/routes/api.php'; require __DIR__.'/../app/Domains/Tenant/routes/api.php'; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php new file mode 100644 index 0000000..d4f4a7c --- /dev/null +++ b/tests/Feature/Cart/CartControllerTest.php @@ -0,0 +1,303 @@ + 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + $this->getJson('/api/tenants/acme/cart') + ->assertOk() + ->assertJson([ + '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 = ProductAttribute::query()->create([ + 'tenant_codigo' => 'acme', + 'codigo' => 'color', + 'nombre' => 'Color', + 'type' => 'text', + ]); + + ProductVariantDefinition::query()->create([ + 'producto_variante_id' => $variant->id, + 'attribute_id' => $attribute->id, + 'value' => 'Red', + ]); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 2, + ]); + + $response + ->assertOk() + ->assertCookie('guest_token') + ->assertJsonPath('tenant_codigo', 'acme') + ->assertJsonPath('items.0.cantidad', 2) + ->assertJsonPath('items.0.precio_unitario', '49.90') + ->assertJsonPath('items.0.subtotal', '99.80') + ->assertJsonPath('items.0.product.id', $variant->product->id) + ->assertJsonPath('items.0.variant.id', $variant->id) + ->assertJsonPath('items.0.variant.definitions.0.attribute.codigo', 'color') + ->assertJsonPath('subtotal', '99.80'); + + $this->assertDatabaseHas('carritos', [ + 'tenant_codigo' => 'acme', + 'guest_token' => $response->getCookie('guest_token')?->getValue(), + 'status' => 'active', + ]); + + $this->assertDatabaseHas('carrito_items', [ + 'producto_variante_id' => $variant->id, + 'cantidad' => 2, + ]); + + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock' => 8, + ]); + } + + 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', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 2, + ]); + + $guestToken = $firstResponse->getCookie('guest_token')?->getValue(); + + $this->withCookie('guest_token', $guestToken) + ->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 3, + ]) + ->assertOk() + ->assertJsonPath('items.0.cantidad', 5) + ->assertJsonPath('items.0.subtotal', '125.00') + ->assertJsonPath('subtotal', '125.00'); + + $this->assertDatabaseCount('carritos', 1); + $this->assertDatabaseCount('carrito_items', 1); + $this->assertDatabaseHas('carrito_items', [ + 'producto_variante_id' => $variant->id, + 'cantidad' => 5, + ]); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock' => 7, + ]); + } + + 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', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 2, + ]); + + $guestToken = $createResponse->getCookie('guest_token')?->getValue(); + + $this->withCookie('guest_token', $guestToken) + ->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [ + 'cantidad' => 5, + ]) + ->assertOk() + ->assertJsonPath('items.0.cantidad', 5) + ->assertJsonPath('items.0.subtotal', '75.00') + ->assertJsonPath('subtotal', '75.00'); + + $this->assertDatabaseHas('carrito_items', [ + 'producto_variante_id' => $variant->id, + 'cantidad' => 5, + ]); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock' => 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', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 4, + ]); + + $guestToken = $createResponse->getCookie('guest_token')?->getValue(); + + $this->withCookie('guest_token', $guestToken) + ->deleteJson("/api/tenants/acme/cart/items/{$variant->id}") + ->assertOk() + ->assertJsonPath('items', []) + ->assertJsonPath('subtotal', '0.00'); + + $this->assertDatabaseCount('carrito_items', 0); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock' => 10, + ]); + } + + 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', [ + 'product_variant_id' => $acmeVariantA->id, + 'cantidad' => 1, + ]) + ->assertOk(); + + $this->actingAs($user) + ->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $acmeVariantB->id, + 'cantidad' => 2, + ]) + ->assertOk() + ->assertJsonPath('subtotal', '50.00'); + + $this->actingAs($user) + ->postJson('/api/tenants/globex/cart/items', [ + 'product_variant_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', [ + 'product_variant_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', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 0, + ])->assertUnprocessable()->assertJsonValidationErrors(['cantidad']); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 2, + ]); + + $guestToken = $response->getCookie('guest_token')?->getValue(); + + $this->withCookie('guest_token', $guestToken) + ->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 1, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['cantidad']); + + $this->withCookie('guest_token', $guestToken) + ->patchJson("/api/tenants/acme/cart/items/{$variant->id}", [ + 'cantidad' => 3, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['cantidad']); + } + + protected function createVariantForTenant( + string $tenantCode, + int $stock, + string $price, + string $slugPrefix = 'shirt', + ): ProductVariant { + Tenant::query()->firstOrCreate( + ['codigo' => $tenantCode], + ['nombre' => ucfirst($tenantCode), 'dominio' => "{$tenantCode}.com"], + ); + + $category = \App\Domains\Catalog\Models\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, + 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), + 'nombre' => ucfirst($slugPrefix).' Variant', + 'stock' => $stock, + 'descripcion' => 'Test variant', + 'precio' => $price, + ])->load('product'); + } +}