From f982bcead10b1cdecf3635220a7b23776621959d Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 15:28:23 -0300 Subject: [PATCH] test(stock): cover authoritative reservation expiration --- tests/Feature/Cart/CartControllerTest.php | 1 + .../Integration/TelepagosWebhookTest.php | 11 ++ tests/Feature/Purchase/StorePurchaseTest.php | 11 +- .../ExpireStockReservationsServiceTest.php | 133 +++++++++++------- .../Unit/Purchase/PurchaseStateGuardTest.php | 12 +- 5 files changed, 106 insertions(+), 62 deletions(-) diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 9af5888..382f96d 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -47,6 +47,7 @@ class CartControllerTest extends TestCase ])); $this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id')); $this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id')); + $this->assertFalse(Schema::hasColumn('compras', 'expires_at')); } public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 9415905..e588810 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -62,6 +62,10 @@ class TelepagosWebhookTest extends TestCase public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void { + config()->set('purchase.payment_expiration_minutes.transfer', 60); + $now = now()->startOfSecond(); + $this->travelTo($now); + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->configureTelepagosIntegration($tenant); $user = User::factory()->create(); @@ -99,6 +103,13 @@ class TelepagosWebhookTest extends TestCase 'payment_method' => 'transfer', 'status' => Purchase::STATUS_PENDING_PAYMENT, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, + 'status' => 'active', + 'expires_at' => $now->copy()->addMinutes(60)->toDateTimeString(), + ]); + + $this->travelBack(); } public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index e18cb00..e882f57 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -538,7 +538,6 @@ class StorePurchaseTest extends TestCase $previousPurchase->update([ 'status' => Purchase::STATUS_PENDING_PAYMENT, 'payment_method' => 'transfer', - 'expires_at' => now()->addMinutes(30), ]); $currentPurchase = app(CheckoutService::class)->startCheckout( @@ -550,7 +549,6 @@ class StorePurchaseTest extends TestCase $this->assertDatabaseHas('compras', [ 'id' => $previousPurchase->id, 'status' => Purchase::STATUS_SUPERSEDED, - 'expires_at' => null, ]); $this->assertDatabaseHas('carritos', [ 'id' => $cart->id, @@ -635,7 +633,7 @@ class StorePurchaseTest extends TestCase ->where('status', 'active') ->firstOrFail(); $cartItemId = $purchase->cart->items()->firstOrFail()->id; - $purchase->update(['expires_at' => now()->subMinute()]); + $purchase->stockReservation()->update(['expires_at' => now()->subMinute()]); $this->artisan('reservations:expire')->assertSuccessful(); @@ -860,7 +858,6 @@ class StorePurchaseTest extends TestCase $purchase->update([ 'payment_method' => 'transfer', 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes(30), ]); $url = "/api/tenants/sonder/compras/{$purchase->id}/review"; @@ -874,6 +871,10 @@ class StorePurchaseTest extends TestCase $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, 'status' => Purchase::STATUS_IN_REVIEW, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $purchase->stock_reservation_id, + 'status' => 'active', 'expires_at' => null, ]); @@ -956,7 +957,7 @@ class StorePurchaseTest extends TestCase ->where('status', 'active') ->firstOrFail(); - $this->assertNotNull($purchase->expires_at); + $this->assertNotNull($purchase->stockReservation->expires_at); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, 'reserved_stock' => 3, diff --git a/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php index 721603d..1abef5c 100644 --- a/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php +++ b/tests/Unit/Catalog/ExpireStockReservationsServiceTest.php @@ -2,77 +2,102 @@ namespace Tests\Unit\Catalog; -use App\Domains\Cart\Services\ExpireCartReservationsService; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\StockReservation; +use App\Domains\Catalog\Models\StockReservationLine; use App\Domains\Catalog\Services\ExpireStockReservationsService; -use App\Domains\Purchase\Services\CheckoutService; -use Illuminate\Support\Facades\Log; -use Psr\Log\LoggerInterface; +use App\Domains\Catalog\Services\StockReservationService; +use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; +use Illuminate\Foundation\Testing\RefreshDatabase; use RuntimeException; use Tests\TestCase; class ExpireStockReservationsServiceTest extends TestCase { - public function test_it_expires_purchases_before_abandoned_cart_reservations(): void + use RefreshDatabase; + + public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void { - $checkout = \Mockery::mock(CheckoutService::class); - $checkout->shouldReceive('expireOverduePurchases') - ->once() - ->ordered() - ->andReturn(2); + $inventory = Inventory::query()->create([ + 'real_stock' => 10, + 'reserved_stock' => 2, + ]); + $reservation = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ]); + StockReservationLine::query()->create([ + 'stock_reservation_id' => $reservation->id, + 'inventory_id' => $inventory->id, + 'quantity' => 2, + 'tracks_inventory' => true, + ]); - $carts = \Mockery::mock(ExpireCartReservationsService::class); - $carts->shouldReceive('expireOverdue') - ->once() - ->ordered() - ->andReturn(3); - - $logger = \Mockery::mock(LoggerInterface::class); - Log::shouldReceive('channel') - ->once() - ->with('commands') - ->andReturn($logger); - $logger->shouldReceive('info') - ->once() - ->with('Stock reservation cleanup completed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => 2, - 'expired_cart_reservations' => 3, - 'total_expired' => 5, - ]); - - $result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue(); + $result = app(ExpireStockReservationsService::class)->expireOverdue(); $this->assertSame([ - 'purchases' => 2, - 'cart_reservations' => 3, + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 1, + 'failed' => 0, ], $result); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $reservation->id, + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + ]); + $this->assertDatabaseHas('inventories', [ + 'id' => $inventory->id, + 'reserved_stock' => 0, + ]); } - public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void + public function test_one_failed_reservation_does_not_stop_the_remaining_batch(): void { - $exception = new RuntimeException('Unable to clean carts.'); - $checkout = \Mockery::mock(CheckoutService::class); - $checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2); + $first = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinutes(2), + ]); + $second = StockReservation::query()->create([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ]); - $carts = \Mockery::mock(ExpireCartReservationsService::class); - $carts->shouldReceive('expireOverdue')->once()->andThrow($exception); + $reservations = \Mockery::mock(StockReservationService::class); + $reservations->shouldReceive('expire') + ->twice() + ->andReturnUsing(function (StockReservation $reservation) use ($first): void { + if ($reservation->is($first)) { + throw new RuntimeException('Broken reservation.'); + } - $logger = \Mockery::mock(LoggerInterface::class); - Log::shouldReceive('channel') - ->once() - ->with('commands') - ->andReturn($logger); - $logger->shouldReceive('error') - ->once() - ->with('Stock reservation cleanup failed.', [ - 'command' => 'reservations:expire', - 'expired_purchases' => 2, - 'expired_cart_reservations' => null, - 'exception' => $exception, - ]); + $reservation->update([ + 'status' => StockReservation::STATUS_EXPIRED, + 'expires_at' => null, + 'expired_at' => now(), + ]); + }); - $this->expectExceptionObject($exception); + $service = new ExpireStockReservationsService( + \Mockery::mock(ReleaseCheckoutService::class), + $reservations, + ); - (new ExpireStockReservationsService($checkout, $carts))->expireOverdue(); + $result = $service->expireOverdue(); + + $this->assertSame([ + 'purchases' => 0, + 'cart_reservations' => 0, + 'orphan_reservations' => 1, + 'failed' => 1, + ], $result); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $first->id, + 'status' => StockReservation::STATUS_ACTIVE, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'id' => $second->id, + 'status' => StockReservation::STATUS_EXPIRED, + ]); } } diff --git a/tests/Unit/Purchase/PurchaseStateGuardTest.php b/tests/Unit/Purchase/PurchaseStateGuardTest.php index f083fdd..9cead5d 100644 --- a/tests/Unit/Purchase/PurchaseStateGuardTest.php +++ b/tests/Unit/Purchase/PurchaseStateGuardTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Purchase; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\PurchaseStateGuard; @@ -22,7 +23,6 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_EXPIRED, - 'expires_at' => null, ]); $this->expectException(PurchaseExpiredException::class); @@ -34,8 +34,11 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->subMinute(), ]); + $purchase->setRelation('stockReservation', (new StockReservation)->forceFill([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ])); $this->expectException(PurchaseExpiredException::class); @@ -46,8 +49,11 @@ class PurchaseStateGuardTest extends TestCase { $purchase = (new Purchase)->forceFill([ 'status' => Purchase::STATUS_PAID, - 'expires_at' => now()->subMinute(), ]); + $purchase->setRelation('stockReservation', (new StockReservation)->forceFill([ + 'status' => StockReservation::STATUS_ACTIVE, + 'expires_at' => now()->subMinute(), + ])); $this->guard->assertNotExpired($purchase);