From ec67623b7b9035d8f4e2d6365d75eee4f3673466 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 15:58:47 -0300 Subject: [PATCH] test(stock): cover terminal reservation lifecycle --- tests/Feature/Cart/CartControllerTest.php | 37 ++++++++- tests/Feature/Purchase/StorePurchaseTest.php | 87 ++++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 6fb4062..89c14ef 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -208,6 +208,10 @@ class CartControllerTest extends TestCase ->expectsOutput('Expired cart reservations: 0') ->assertSuccessful(); + $this->postJson('/api/tenants/acme/cart/restart') + ->assertUnprocessable() + ->assertJsonValidationErrors('cart'); + $this->travel(31)->minutes(); $this->artisan('reservations:expire') @@ -224,10 +228,11 @@ class CartControllerTest extends TestCase $this->assertDatabaseHas('carritos', [ 'id' => $cartId, 'status' => 'active', - 'current_stock_reservation_id' => null, + 'current_stock_reservation_id' => $reservationId, 'deleted_at' => null, ]); $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, 'status' => 'expired', 'expires_at' => null, ]); @@ -236,6 +241,36 @@ class CartControllerTest extends TestCase 'quantity' => 2, ]); + $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 1, + ]) + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $restart = $this->postJson('/api/tenants/acme/cart/restart') + ->assertOk() + ->assertJsonPath('code', 'cart.restarted') + ->assertJsonPath('data.items', []) + ->assertJsonMissingPath('data.stock_reservation') + ->assertJsonMissingPath('data.current_stock_reservation_id'); + $newCartId = $restart->json('data.id'); + + $this->assertNotSame($cartId, $newCartId); + $this->assertDatabaseHas('carritos', [ + 'id' => $cartId, + 'status' => 'abandoned', + 'current_stock_reservation_id' => $reservationId, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $newCartId, + 'status' => 'active', + 'current_stock_reservation_id' => null, + ]); + $this->artisan('reservations:expire') ->expectsOutput('Expired purchases: 0') ->expectsOutput('Expired cart reservations: 0') diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 3184b75..019cf9d 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -71,6 +71,43 @@ class StorePurchaseTest extends TestCase $this->travelBack(); } + public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $user->id, + 'status' => 'active', + ]); + $cart->addItem($variant->catalog_item_id, $variant->id, 2); + $reservationId = $cart->fresh()->current_stock_reservation_id; + $expiredAt = now()->subMinute()->startOfSecond(); + $cart->currentStockReservation()->update(['expires_at' => $expiredAt]); + + $this->actingAs($user, 'sanctum') + ->postJson('/api/tenants/sonder/compras/start-checkout', [ + 'cart_id' => $cart->id, + ]) + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $this->assertDatabaseCount('compras', 0); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservationId, + 'status' => 'active', + 'expires_at' => $expiredAt->toDateTimeString(), + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $cart->id, + 'current_stock_reservation_id' => $reservationId, + ]); + } + public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); @@ -514,7 +551,11 @@ class StorePurchaseTest extends TestCase 'reserved_stock' => 3, ]); $activeCart->refresh(); - $this->assertNotNull($activeCart->current_stock_reservation_id); + $this->assertSame($reservationId, $activeCart->current_stock_reservation_id); + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'stock_reservation_id' => null, + ]); $this->assertDatabaseHas('stock_reservations', [ 'id' => $activeCart->current_stock_reservation_id, 'status' => 'active', @@ -555,11 +596,15 @@ class StorePurchaseTest extends TestCase 'id' => $cart->id, 'current_purchase_id' => $currentPurchase->id, ]); - $this->assertNotSame($previousReservationId, $currentPurchase->stock_reservation_id); + $this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id); + $this->assertDatabaseHas('compras', [ + 'id' => $previousPurchase->id, + 'stock_reservation_id' => null, + ]); $this->assertDatabaseHas('stock_reservations', [ 'id' => $previousReservationId, - 'status' => 'released', - 'release_reason' => 'purchase_superseded', + 'status' => 'active', + 'release_reason' => null, ]); $this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active'); @@ -605,7 +650,7 @@ class StorePurchaseTest extends TestCase ]); $this->assertDatabaseHas('stock_reservations', [ 'status' => 'released', - 'release_reason' => 'purchase_superseded', + 'release_reason' => 'cart_empty', ]); $this->assertDatabaseHas('stock_reservation_lines', [ 'inventory_id' => $variant->inventory_id, @@ -656,6 +701,38 @@ class StorePurchaseTest extends TestCase ]); $this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired'); $this->assertSame(1, $activeCart->items()->count()); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertJsonPath('code', 'purchase.expired'); + } + + public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant('sonder', 10, '50.00'); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); + $purchase->stockReservation()->update(['expires_at' => now()->subMinute()]); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'stock_reservation.expired', + 'message' => __('api.cart.reservation_expired'), + ]); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_CREATED, + 'stock_reservation_id' => $purchase->stock_reservation_id, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, + 'status' => 'active', + ]); } public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void