350 lines
10 KiB
PHP
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);
|
|
}
|
|
}
|