From e14b1b870974c695f0a298419533866ad87b8cb9 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Mon, 9 Feb 2026 15:49:48 +0000 Subject: [PATCH 1/4] add plans to git ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2ce74fd5c..4d8a44035 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ prompts/ /playground/** /playground/ + +/plans/** +/plans From 5c4bb11a2ad0d03748d20f24fc9f71ea8934eb1a Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 11 Mar 2026 07:36:18 +0000 Subject: [PATCH 2/4] Fix waitlist race conditions and capacity double-counting - Move duplicate validation inside transaction in CreateWaitlistEntryService - Add row-level locking and status re-check in ProcessExpiredWaitlistOffersJob - Remove double-counting of offered entries in ProcessWaitlistService capacity check - Add pg_advisory_xact_lock to serialize waitlist offers against regular checkouts --- .../ProcessExpiredWaitlistOffersJob.php | 52 +++++++++++++------ .../Waitlist/CreateWaitlistEntryService.php | 2 +- .../Waitlist/ProcessWaitlistService.php | 35 +++++-------- 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php index 14102c3bd..0e8f316bd 100644 --- a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php +++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php @@ -11,9 +11,11 @@ use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\DatabaseManager; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Throwable; @@ -25,6 +27,7 @@ public function handle( WaitlistEntryRepositoryInterface $repository, OrderRepositoryInterface $orderRepository, ProductPriceRepositoryInterface $productPriceRepository, + DatabaseManager $databaseManager, ): void { $expiredEntries = $repository->findWhere([ @@ -35,23 +38,40 @@ public function handle( foreach ($expiredEntries as $entry) { try { - if ($entry->getOrderId() !== null) { - $orderRepository->deleteWhere([ - 'id' => $entry->getOrderId(), - 'status' => OrderStatus::RESERVED->name, - ]); - } + $databaseManager->transaction(function () use ($entry, $repository, $orderRepository) { + $lockedEntry = DB::table('waitlist_entries') + ->where('id', $entry->getId()) + ->lockForUpdate() + ->first(); + + if ($lockedEntry === null || $lockedEntry->status !== WaitlistEntryStatus::OFFERED->name) { + return; + } + + if ($lockedEntry->order_id !== null) { + $orderRepository->deleteWhere([ + 'id' => $lockedEntry->order_id, + 'status' => OrderStatus::RESERVED->name, + ]); + } - $repository->updateWhere( - attributes: [ - 'status' => WaitlistEntryStatus::OFFER_EXPIRED->name, - 'offer_token' => null, - 'offered_at' => null, - 'offer_expires_at' => null, - 'order_id' => null, - ], - where: ['id' => $entry->getId()], - ); + $repository->updateWhere( + attributes: [ + 'status' => WaitlistEntryStatus::OFFER_EXPIRED->name, + 'offer_token' => null, + 'offered_at' => null, + 'offer_expires_at' => null, + 'order_id' => null, + ], + where: ['id' => $entry->getId()], + ); + }); + + $freshEntry = $repository->findById($entry->getId()); + + if ($freshEntry->getStatus() !== WaitlistEntryStatus::OFFER_EXPIRED->name) { + continue; + } SendWaitlistOfferExpiredEmailJob::dispatch($entry); diff --git a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php index 62971e714..b4e8c814e 100644 --- a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php +++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php @@ -32,11 +32,11 @@ public function createEntry( ): WaitlistEntryDomainObject { $this->validateWaitlistEnabled($product); - $this->validateNoDuplicate($dto); /** @var WaitlistEntryDomainObject $entry */ $entry = $this->databaseManager->transaction(function () use ($dto) { $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id); + $this->validateNoDuplicate($dto); $position = $this->calculatePosition($dto); return $this->waitlistEntryRepository->create([ diff --git a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php index beaaf73a1..eedc88ffa 100644 --- a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php +++ b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php @@ -53,6 +53,7 @@ public function offerToNext( ): Collection { return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); $this->waitlistEntryRepository->lockForProductPrice($productPriceId); $quantities = $this->availableQuantitiesService->getAvailableProductQuantities( @@ -60,25 +61,17 @@ public function offerToNext( ignoreCache: true, ); - $actualAvailable = $this->getAvailableCountForPrice($quantities, $productPriceId); + $availableCount = $this->getAvailableCountForPrice($quantities, $productPriceId); - $currentlyOfferedCount = $this->waitlistEntryRepository->countWhere([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]); - - $effectiveAvailable = $actualAvailable - $currentlyOfferedCount; - - if ($effectiveAvailable <= 0) { + if ($availableCount <= 0) { throw new NoCapacityAvailableException( - __('No capacity available. Available: :available, reserved by pending offers: :offered', [ - 'available' => $actualAvailable, - 'offered' => $currentlyOfferedCount, + __('No capacity available. Available: :available', [ + 'available' => $availableCount, ]) ); } - $toOffer = min($quantity, $effectiveAvailable); + $toOffer = min($quantity, $availableCount); $entries = $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, $toOffer); if ($entries->isEmpty()) { @@ -109,6 +102,8 @@ public function offerSpecificEntry( ): Collection { return $this->databaseManager->transaction(function () use ($entryId, $eventId, $event, $eventSettings) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]); + /** @var WaitlistEntryDomainObject|null $entry */ $entry = $this->waitlistEntryRepository->findFirstWhere([ 'id' => $entryId, @@ -133,18 +128,12 @@ public function offerSpecificEntry( ignoreCache: true, ); - $actualAvailable = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()); - - $currentlyOfferedCount = $this->waitlistEntryRepository->countWhere([ - 'product_price_id' => $entry->getProductPriceId(), - 'status' => WaitlistEntryStatus::OFFERED->name, - ]); + $availableCount = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()); - if ($actualAvailable <= $currentlyOfferedCount) { + if ($availableCount <= 0) { throw new NoCapacityAvailableException( - __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available, already offered: :offered', [ - 'available' => $actualAvailable, - 'offered' => $currentlyOfferedCount, + __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available', [ + 'available' => $availableCount, ]) ); } From 6f558d5bb55707f47ee636f642e8ade85c5d2acc Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 11 Mar 2026 07:38:38 +0000 Subject: [PATCH 3/4] Refactor ProcessExpiredWaitlistOffersJob to use repository instead of direct DB access --- .../Waitlist/ProcessExpiredWaitlistOffersJob.php | 12 ++++-------- .../Eloquent/WaitlistEntryRepository.php | 14 ++++++++++++++ .../WaitlistEntryRepositoryInterface.php | 2 ++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php index 0e8f316bd..1c17ce497 100644 --- a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php +++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php @@ -15,7 +15,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Throwable; @@ -39,18 +38,15 @@ public function handle( foreach ($expiredEntries as $entry) { try { $databaseManager->transaction(function () use ($entry, $repository, $orderRepository) { - $lockedEntry = DB::table('waitlist_entries') - ->where('id', $entry->getId()) - ->lockForUpdate() - ->first(); + $lockedEntry = $repository->findByIdLocked($entry->getId()); - if ($lockedEntry === null || $lockedEntry->status !== WaitlistEntryStatus::OFFERED->name) { + if ($lockedEntry === null || $lockedEntry->getStatus() !== WaitlistEntryStatus::OFFERED->name) { return; } - if ($lockedEntry->order_id !== null) { + if ($lockedEntry->getOrderId() !== null) { $orderRepository->deleteWhere([ - 'id' => $lockedEntry->order_id, + 'id' => $lockedEntry->getOrderId(), 'status' => OrderStatus::RESERVED->name, ]); } diff --git a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php index 996058239..bb27bf8c5 100644 --- a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php +++ b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php @@ -122,6 +122,20 @@ public function lockForProductPrice(int $productPriceId): void ->get(); } + public function findByIdLocked(int $id): ?WaitlistEntryDomainObject + { + $model = WaitlistEntry::query() + ->where('id', $id) + ->lockForUpdate() + ->first(); + + if ($model === null) { + return null; + } + + return $this->handleSingleResult($model); + } + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator { $where = [ diff --git a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php index 12f790b06..99d6901c7 100644 --- a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php @@ -27,4 +27,6 @@ public function getMaxPosition(int $productPriceId): int; public function getNextWaitingEntries(int $productPriceId, int $limit): Collection; public function lockForProductPrice(int $productPriceId): void; + + public function findByIdLocked(int $id): ?WaitlistEntryDomainObject; } From bcaa6dd6952273644fa860996b43a8dd8217901d Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 11 Mar 2026 21:19:34 +0000 Subject: [PATCH 4/4] Fix unit tests for waitlist race condition and capacity changes --- .../ProcessExpiredWaitlistOffersJobTest.php | 101 +++++++++++++- .../CreateWaitlistEntryServiceTest.php | 5 + .../Waitlist/ProcessWaitlistServiceTest.php | 127 ++---------------- 3 files changed, 111 insertions(+), 122 deletions(-) diff --git a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php index f26670779..1bc129d44 100644 --- a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php +++ b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php @@ -12,6 +12,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface; +use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; @@ -24,6 +25,7 @@ class ProcessExpiredWaitlistOffersJobTest extends TestCase private WaitlistEntryRepositoryInterface $repository; private OrderRepositoryInterface $orderRepository; private ProductPriceRepositoryInterface $productPriceRepository; + private DatabaseManager $databaseManager; protected function setUp(): void { @@ -31,6 +33,13 @@ protected function setUp(): void $this->repository = m::mock(WaitlistEntryRepositoryInterface::class); $this->orderRepository = m::mock(OrderRepositoryInterface::class); $this->productPriceRepository = m::mock(ProductPriceRepositoryInterface::class); + $this->databaseManager = m::mock(DatabaseManager::class); + + $this->databaseManager + ->shouldReceive('transaction') + ->andReturnUsing(function ($callback) { + return $callback(); + }); $productPrice = new ProductPriceDomainObject(); $productPrice->setId(20); @@ -59,6 +68,12 @@ public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void ->once() ->andReturn(new Collection([$entry])); + $this->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(1) + ->andReturn($entry); + $this->orderRepository ->shouldReceive('deleteWhere') ->once() @@ -81,8 +96,20 @@ public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void ['id' => 1], ); + $expiredEntry = new WaitlistEntryDomainObject(); + $expiredEntry->setId(1); + $expiredEntry->setEventId(10); + $expiredEntry->setProductPriceId(20); + $expiredEntry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($expiredEntry); + $job = new ProcessExpiredWaitlistOffersJob(); - $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class); Event::assertDispatched(CapacityChangedEvent::class, function ($event) { @@ -107,14 +134,32 @@ public function testSkipsOrderDeletionWhenNoOrderId(): void ->once() ->andReturn(new Collection([$entry])); + $this->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(2) + ->andReturn($entry); + $this->orderRepository->shouldNotReceive('deleteWhere'); $this->repository ->shouldReceive('updateWhere') ->once(); + $expiredEntry = new WaitlistEntryDomainObject(); + $expiredEntry->setId(2); + $expiredEntry->setEventId(10); + $expiredEntry->setProductPriceId(20); + $expiredEntry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(2) + ->andReturn($expiredEntry); + $job = new ProcessExpiredWaitlistOffersJob(); - $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class); Event::assertDispatched(CapacityChangedEvent::class); @@ -131,7 +176,7 @@ public function testDoesNothingWhenNoExpiredEntries(): void ->andReturn(new Collection()); $job = new ProcessExpiredWaitlistOffersJob(); - $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); Event::assertNotDispatched(CapacityChangedEvent::class); @@ -162,19 +207,63 @@ public function testCatchesExceptionAndLogsError(): void ->once() ->andReturn(new Collection([$entry])); - $this->orderRepository - ->shouldReceive('deleteWhere') + $this->repository + ->shouldReceive('findByIdLocked') ->once() + ->with(1) ->andThrow(new \RuntimeException('DB connection lost')); $job = new ProcessExpiredWaitlistOffersJob(); - $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); $this->assertTrue($logged, 'Error was logged for failed expired offer processing'); Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); Event::assertNotDispatched(CapacityChangedEvent::class); } + public function testSkipsEntryWhenStatusChangedBeforeLock(): void + { + Bus::fake(); + Event::fake(); + + $entry = new WaitlistEntryDomainObject(); + $entry->setId(1); + $entry->setEventId(10); + $entry->setProductPriceId(20); + $entry->setOrderId(100); + $entry->setStatus(WaitlistEntryStatus::OFFERED->name); + + $this->repository + ->shouldReceive('findWhere') + ->once() + ->andReturn(new Collection([$entry])); + + $cancelledEntry = new WaitlistEntryDomainObject(); + $cancelledEntry->setId(1); + $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name); + + $this->repository + ->shouldReceive('findByIdLocked') + ->once() + ->with(1) + ->andReturn($cancelledEntry); + + $this->orderRepository->shouldNotReceive('deleteWhere'); + $this->repository->shouldNotReceive('updateWhere'); + + $this->repository + ->shouldReceive('findById') + ->once() + ->with(1) + ->andReturn($cancelledEntry); + + $job = new ProcessExpiredWaitlistOffersJob(); + $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager); + + Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class); + Event::assertNotDispatched(CapacityChangedEvent::class); + } + protected function tearDown(): void { m::close(); diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php index 573f26019..95310f406 100644 --- a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php @@ -135,6 +135,11 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void $existingEntry = Mockery::mock(WaitlistEntryDomainObject::class); + $this->waitlistEntryRepository + ->shouldReceive('lockForProductPrice') + ->once() + ->with(10); + $this->waitlistEntryRepository ->shouldReceive('findFirstWhere') ->once() diff --git a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php index 016c26ee7..e307ebba3 100644 --- a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php @@ -57,6 +57,14 @@ protected function setUp(): void ->shouldReceive('lockForProductPrice') ->zeroOrMoreTimes(); + $this->databaseManager + ->shouldReceive('statement') + ->withArgs(function ($sql, $params) { + return $sql === 'SELECT pg_advisory_xact_lock(?)' && is_array($params); + }) + ->zeroOrMoreTimes() + ->andReturn(true); + $this->service = new ProcessWaitlistService( waitlistEntryRepository: $this->waitlistEntryRepository, databaseManager: $this->databaseManager, @@ -160,15 +168,6 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void $event = $this->createMockEvent(); $eventSettings = $this->createMockEventSettings(); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(0); - $this->databaseManager ->shouldReceive('transaction') ->once() @@ -240,11 +239,6 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void $event = $this->createMockEvent(); $eventSettings = $this->createMockEventSettings($timeoutMinutes); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->andReturn(0); - $this->databaseManager ->shouldReceive('transaction') ->once() @@ -308,11 +302,6 @@ public function testCreatesReservedOrderWhenOffering(): void $event = $this->createMockEvent(); $eventSettings = $this->createMockEventSettings(30); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->andReturn(0); - $this->databaseManager ->shouldReceive('transaction') ->once() @@ -422,11 +411,6 @@ public function testThrowsWhenNoWaitingEntries(): void $this->mockAvailableQuantities($event->getId(), $productPriceId); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->andReturn(0); - $this->waitlistEntryRepository ->shouldReceive('getNextWaitingEntries') ->once() @@ -438,7 +422,7 @@ public function testThrowsWhenNoWaitingEntries(): void $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); } - public function testCapsOffersAtEffectiveAvailableCapacity(): void + public function testCapsOffersAtAvailableCapacity(): void { Bus::fake(); @@ -447,15 +431,6 @@ public function testCapsOffersAtEffectiveAvailableCapacity(): void $event = $this->createMockEvent(); $eventSettings = $this->createMockEventSettings(); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(2); - $this->databaseManager ->shouldReceive('transaction') ->once() @@ -463,7 +438,7 @@ public function testCapsOffersAtEffectiveAvailableCapacity(): void return $callback(); }); - $this->mockAvailableQuantities($event->getId(), $productPriceId); + $this->mockAvailableQuantities($event->getId(), $productPriceId, 1); $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class); $waitingEntry->shouldReceive('getId')->andReturn(1); @@ -496,36 +471,6 @@ public function testCapsOffersAtEffectiveAvailableCapacity(): void $this->assertCount(1, $result); } - public function testThrowsWhenAllCapacityReservedByOffers(): void - { - $productPriceId = 10; - $quantity = 2; - $event = $this->createMockEvent(); - $eventSettings = $this->createMockEventSettings(); - - $this->databaseManager - ->shouldReceive('transaction') - ->once() - ->andReturnUsing(function ($callback) { - return $callback(); - }); - - $this->mockAvailableQuantities($event->getId(), $productPriceId, 2); - - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(2); - - $this->expectException(NoCapacityAvailableException::class); - - $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); - } - public function testThrowsWhenNoCapacityAtAll(): void { $productPriceId = 10; @@ -542,15 +487,6 @@ public function testThrowsWhenNoCapacityAtAll(): void $this->mockAvailableQuantities($event->getId(), $productPriceId, 0); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(0); - $this->expectException(NoCapacityAvailableException::class); $this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings); @@ -565,11 +501,6 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void $event = $this->createMockEvent(); $eventSettings = $this->createMockEventSettings(null); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->andReturn(0); - $this->databaseManager ->shouldReceive('transaction') ->once() @@ -649,15 +580,6 @@ public function testOfferSpecificEntrySuccessfully(): void $this->mockAvailableQuantities($eventId, $productPriceId, 5); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(0); - $order = $this->mockOrderCreation(); $this->waitlistEntryRepository @@ -775,15 +697,6 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void $this->mockAvailableQuantities($eventId, $productPriceId, 3); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(0); - $this->mockOrderCreation(); $this->waitlistEntryRepository @@ -834,15 +747,6 @@ public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void $this->mockAvailableQuantities($eventId, $productPriceId, 0); - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(0); - $this->expectException(NoCapacityAvailableException::class); $this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings); @@ -875,16 +779,7 @@ public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void ->with(['id' => $entryId, 'event_id' => $eventId]) ->andReturn($entry); - $this->mockAvailableQuantities($eventId, $productPriceId, 2); - - $this->waitlistEntryRepository - ->shouldReceive('countWhere') - ->once() - ->with([ - 'product_price_id' => $productPriceId, - 'status' => WaitlistEntryStatus::OFFERED->name, - ]) - ->andReturn(2); + $this->mockAvailableQuantities($eventId, $productPriceId, 0); $this->expectException(NoCapacityAvailableException::class);