mivita/tests/Feature/Payment/PayoneRaceConditionTest.php
2026-02-20 17:55:06 +01:00

350 lines
10 KiB
PHP

<?php
namespace Tests\Feature\Payment;
use App\Models\Setting;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserShop;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Tests for Payone payment race condition scenarios
*/
class PayoneRaceConditionTest extends TestCase
{
use DatabaseTransactions;
protected $testShoppingOrder;
protected $testShoppingPayment;
protected $testShoppingUser;
protected function setUp(): void
{
parent::setUp();
// Initialize invoice number
if (! Setting::where('slug', 'invoice-number')->exists()) {
Setting::create([
'slug' => 'invoice-number',
'type' => 'int',
'int' => 5000,
]);
}
// Create test data
$this->createTestData();
}
protected function createTestData()
{
$country = ShippingCountry::first();
$userShop = UserShop::first() ?? UserShop::create([
'name' => 'Test Shop',
'slug' => 'test-shop-payone',
'domain' => 'test-payone.local',
]);
$this->testShoppingUser = ShoppingUser::create([
'billing_firstname' => 'Payone',
'billing_lastname' => 'Test',
'billing_email' => 'payone-test@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
$this->testShoppingOrder = ShoppingOrder::create([
'shopping_user_id' => $this->testShoppingUser->id,
'country_id' => $country->id,
'user_shop_id' => $userShop->id,
'total' => 89.00,
'paid' => false,
'txaction' => null,
'mode' => 'test',
]);
$this->testShoppingPayment = ShoppingPayment::create([
'shopping_order_id' => $this->testShoppingOrder->id,
'reference' => 'test-ref-'.uniqid(),
'amount' => 8900, // in cents
'txaction' => null,
'mode' => 'test',
]);
}
/**
* Test that shopping order can be locked for payment processing
*
* @test
*/
public function it_locks_shopping_order_during_payment_processing()
{
DB::transaction(function () {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$this->assertNotNull($lockedOrder);
$this->assertFalse((bool) $lockedOrder->paid);
$this->assertEquals($this->testShoppingOrder->id, $lockedOrder->id);
});
}
/**
* Test double payment prevention with lock
*
* @test
*/
public function it_prevents_double_payment_processing()
{
// First payment processing
DB::transaction(function () {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$this->assertFalse((bool) $lockedOrder->paid);
// Mark as paid
$lockedOrder->paid = true;
$lockedOrder->txaction = 'paid';
$lockedOrder->save();
});
// Second payment attempt (should see order already paid)
DB::transaction(function () {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
// Should already be marked as paid (double-check pattern)
$this->assertTrue((bool) $lockedOrder->paid);
$this->assertEquals('paid', $lockedOrder->txaction);
// Would skip payment processing
});
}
/**
* Test that multiple payment requests for same order are serialized
*
* @test
*/
public function it_serializes_concurrent_payment_requests()
{
$processedCount = 0;
$skippedCount = 0;
// Simulate 3 concurrent payment notifications
for ($i = 0; $i < 3; $i++) {
DB::transaction(function () use (&$processedCount, &$skippedCount) {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
if (! $lockedOrder->paid) {
// Process payment
$lockedOrder->paid = true;
$lockedOrder->txaction = 'paid';
$lockedOrder->save();
$processedCount++;
} else {
// Skip - already processed
$skippedCount++;
}
});
}
// Only one should have processed, others should skip
$this->assertEquals(1, $processedCount, 'Only one payment should be processed');
$this->assertEquals(2, $skippedCount, 'Two payment attempts should be skipped');
}
/**
* Test transaction rollback on error
*
* @test
*/
public function it_rolls_back_payment_on_error()
{
try {
DB::transaction(function () {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$lockedOrder->paid = true;
$lockedOrder->save();
// Simulate an error
throw new \Exception('Simulated payment processing error');
});
} catch (\Exception $e) {
// Expected exception
}
// Order should not be marked as paid
$this->testShoppingOrder->refresh();
$this->assertFalse((bool) $this->testShoppingOrder->paid);
}
/**
* Test that payment status transitions are tracked correctly
*
* @test
*/
public function it_tracks_payment_status_transitions()
{
$statuses = ['appointed', 'paid'];
foreach ($statuses as $status) {
DB::transaction(function () use ($status) {
$lockedOrder = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$lockedOrder->txaction = $status;
if ($status === 'paid') {
$lockedOrder->paid = true;
}
$lockedOrder->save();
});
$this->testShoppingOrder->refresh();
$this->assertEquals($status, $this->testShoppingOrder->txaction);
}
}
/**
* Test concurrent payments for different orders
*
* @test
*/
public function it_handles_concurrent_payments_for_different_orders()
{
// Create second order
$order2 = ShoppingOrder::create([
'shopping_user_id' => $this->testShoppingUser->id,
'country_id' => $this->testShoppingOrder->country_id,
'user_shop_id' => $this->testShoppingOrder->user_shop_id,
'total' => 150.00,
'paid' => false,
'txaction' => null,
'mode' => 'test',
]);
// Process both orders "concurrently"
DB::transaction(function () {
$locked1 = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$locked1->paid = true;
$locked1->txaction = 'paid';
$locked1->save();
});
DB::transaction(function () use ($order2) {
$locked2 = ShoppingOrder::where('id', $order2->id)
->lockForUpdate()
->first();
$locked2->paid = true;
$locked2->txaction = 'paid';
$locked2->save();
});
// Both should be processed
$this->testShoppingOrder->refresh();
$order2->refresh();
$this->assertTrue((bool) $this->testShoppingOrder->paid);
$this->assertTrue((bool) $order2->paid);
}
/**
* Test payment transaction isolation
*
* @test
*/
public function it_isolates_payment_transactions()
{
$visibilityTest = false;
// Start transaction 1
DB::beginTransaction();
$order1 = ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
$order1->paid = true;
$order1->save();
// Transaction 2 should see uncommitted data due to lock
// (but would block in real concurrent scenario)
$order2 = ShoppingOrder::find($this->testShoppingOrder->id);
// In same process, find() bypasses lock but doesn't see uncommitted changes
// until commit (depending on isolation level)
DB::commit();
// Now it should be visible
$this->testShoppingOrder->refresh();
$this->assertTrue((bool) $this->testShoppingOrder->paid);
}
/**
* Test that locked rows cannot be modified by other transactions
*
* @test
*/
public function it_prevents_concurrent_modifications()
{
$this->expectNotToPerformAssertions();
// This test documents the locking behavior
DB::transaction(function () {
ShoppingOrder::where('id', $this->testShoppingOrder->id)
->lockForUpdate()
->first();
// In a real concurrent scenario, another transaction trying to lock
// the same row would wait here until this transaction commits
});
}
/**
* Test Payone payment reference uniqueness
*
* @test
*/
public function it_enforces_unique_payment_references()
{
$reference = 'unique-ref-'.uniqid();
$payment1 = ShoppingPayment::create([
'shopping_order_id' => $this->testShoppingOrder->id,
'reference' => $reference,
'amount' => 5000,
'mode' => 'test',
]);
$this->assertEquals($reference, $payment1->reference);
// Creating another payment with same reference should be allowed by code
// (validation happens in controller logic, not database constraint)
$payment2 = ShoppingPayment::create([
'shopping_order_id' => $this->testShoppingOrder->id,
'reference' => $reference,
'amount' => 5000,
'mode' => 'test',
]);
$this->assertNotEquals($payment1->id, $payment2->id);
}
}