335 lines
9.9 KiB
PHP
335 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Payment;
|
|
|
|
use App\Models\Setting;
|
|
use App\Models\ShippingCountry;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserShop;
|
|
use App\Services\Invoice;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Tests for concurrent payment processing and invoice number race conditions
|
|
*/
|
|
class ConcurrentPaymentTest extends TestCase
|
|
{
|
|
use DatabaseTransactions;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Initialize invoice number setting if not exists
|
|
if (! Setting::where('slug', 'invoice-number')->exists()) {
|
|
Setting::create([
|
|
'slug' => 'invoice-number',
|
|
'type' => 'int',
|
|
'int' => 1000,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test that invoice numbers are unique when created concurrently
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_generates_unique_invoice_numbers_under_concurrent_load()
|
|
{
|
|
$initialNumber = Invoice::getInvoiceNumber();
|
|
$iterations = 10;
|
|
$invoiceNumbers = [];
|
|
|
|
// Simulate concurrent invoice number generation
|
|
for ($i = 0; $i < $iterations; $i++) {
|
|
$number = Invoice::makeNextInvoiceNumber();
|
|
$invoiceNumbers[] = $number;
|
|
}
|
|
|
|
// Assert all numbers are unique
|
|
$this->assertCount($iterations, array_unique($invoiceNumbers), 'Invoice numbers must be unique');
|
|
|
|
// Assert numbers are sequential
|
|
for ($i = 0; $i < $iterations; $i++) {
|
|
$this->assertEquals(
|
|
$initialNumber + $i + 1,
|
|
$invoiceNumbers[$i],
|
|
"Invoice number at position {$i} should be sequential"
|
|
);
|
|
}
|
|
|
|
// Assert final number is correct
|
|
$finalNumber = Invoice::getInvoiceNumber();
|
|
$this->assertEquals(
|
|
$initialNumber + $iterations,
|
|
$finalNumber,
|
|
'Final invoice number should match expected value'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test that makeNextInvoiceNumber is atomic and thread-safe
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_atomically_increments_invoice_numbers()
|
|
{
|
|
$initialNumber = Invoice::getInvoiceNumber();
|
|
|
|
// Run in separate database transactions to simulate concurrency
|
|
$results = [];
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
DB::transaction(function () use (&$results) {
|
|
$number = Invoice::makeNextInvoiceNumber();
|
|
$results[] = $number;
|
|
});
|
|
}
|
|
|
|
// All numbers should be unique
|
|
$this->assertCount(5, array_unique($results), 'All invoice numbers must be unique');
|
|
|
|
// Numbers should be in sequence (though order may vary)
|
|
sort($results);
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->assertEquals($initialNumber + $i + 1, $results[$i]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test invoice number rollback on transaction failure
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_does_not_skip_invoice_numbers_on_transaction_rollback()
|
|
{
|
|
$initialNumber = Invoice::getInvoiceNumber();
|
|
|
|
// Successful increment
|
|
$number1 = Invoice::makeNextInvoiceNumber();
|
|
$this->assertEquals($initialNumber + 1, $number1);
|
|
|
|
// Successful increment
|
|
$number2 = Invoice::makeNextInvoiceNumber();
|
|
$this->assertEquals($initialNumber + 2, $number2);
|
|
|
|
// Current number should be initial + 2
|
|
$currentNumber = Invoice::getInvoiceNumber();
|
|
$this->assertEquals($initialNumber + 2, $currentNumber);
|
|
}
|
|
|
|
/**
|
|
* Test that invoice numbers have proper locking mechanism
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_uses_database_locking_for_invoice_numbers()
|
|
{
|
|
$this->expectNotToPerformAssertions();
|
|
|
|
// This test verifies that lockForUpdate is used by checking for no exceptions
|
|
try {
|
|
DB::transaction(function () {
|
|
$setting = Setting::where('slug', 'invoice-number')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$this->assertNotNull($setting);
|
|
|
|
// Simulate processing time
|
|
usleep(100);
|
|
|
|
$setting->int = $setting->int + 1;
|
|
$setting->save();
|
|
});
|
|
} catch (\Exception $e) {
|
|
$this->fail('Database locking should not throw exceptions: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test concurrent shopping order payment processing
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_prevents_double_processing_of_same_payment()
|
|
{
|
|
// Create test data
|
|
$country = ShippingCountry::first();
|
|
|
|
$userShop = UserShop::first() ?? UserShop::create([
|
|
'name' => 'Test Shop',
|
|
'slug' => 'test-shop',
|
|
'domain' => 'test.local',
|
|
]);
|
|
|
|
$shoppingUser = ShoppingUser::create([
|
|
'billing_firstname' => 'Test',
|
|
'billing_lastname' => 'User',
|
|
'billing_email' => 'test@example.com',
|
|
'billing_country_id' => $country->id,
|
|
'shipping_country_id' => $country->id,
|
|
]);
|
|
|
|
$shoppingOrder = ShoppingOrder::create([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'country_id' => $country->id,
|
|
'user_shop_id' => $userShop->id,
|
|
'total' => 100.00,
|
|
'paid' => false,
|
|
'txaction' => null,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
// Lock the order (simulating concurrent access)
|
|
DB::transaction(function () use ($shoppingOrder) {
|
|
$lockedOrder = ShoppingOrder::where('id', $shoppingOrder->id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$this->assertNotNull($lockedOrder);
|
|
$this->assertFalse((bool) $lockedOrder->paid);
|
|
|
|
// Mark as paid
|
|
$lockedOrder->paid = true;
|
|
$lockedOrder->txaction = 'paid';
|
|
$lockedOrder->save();
|
|
});
|
|
|
|
// Verify payment was processed
|
|
$shoppingOrder->refresh();
|
|
$this->assertTrue((bool) $shoppingOrder->paid);
|
|
$this->assertEquals('paid', $shoppingOrder->txaction);
|
|
|
|
// Try to process again (should be prevented by double-check)
|
|
DB::transaction(function () use ($shoppingOrder) {
|
|
$lockedOrder = ShoppingOrder::where('id', $shoppingOrder->id)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
// Should already be paid
|
|
$this->assertTrue((bool) $lockedOrder->paid);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test that Setting model lockForUpdate works correctly
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_can_lock_settings_for_update()
|
|
{
|
|
DB::transaction(function () {
|
|
$setting = Setting::where('slug', 'invoice-number')
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
$this->assertNotNull($setting);
|
|
$this->assertEquals('invoice-number', $setting->slug);
|
|
$this->assertEquals('int', $setting->type);
|
|
$this->assertIsInt($setting->int);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test invoice number creation with date prefix
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_creates_invoice_numbers_with_correct_format()
|
|
{
|
|
$number = 123;
|
|
$date = '01.01.2024';
|
|
|
|
$invoiceNumber = Invoice::createInvoiceNumber($number, $date);
|
|
|
|
$this->assertEquals('202400123', $invoiceNumber);
|
|
$this->assertStringStartsWith('2024', $invoiceNumber);
|
|
}
|
|
|
|
/**
|
|
* Test multiple sequential invoice creations
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_handles_rapid_sequential_invoice_creation()
|
|
{
|
|
$initialNumber = Invoice::getInvoiceNumber();
|
|
$count = 20;
|
|
$numbers = [];
|
|
|
|
// Rapidly create invoice numbers
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$numbers[] = Invoice::makeNextInvoiceNumber();
|
|
}
|
|
|
|
// Verify all unique
|
|
$unique = array_unique($numbers);
|
|
$this->assertCount($count, $unique, 'All invoice numbers must be unique');
|
|
|
|
// Verify no gaps in sequence
|
|
sort($numbers);
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$expected = $initialNumber + $i + 1;
|
|
$actual = $numbers[$i];
|
|
$this->assertEquals(
|
|
$expected,
|
|
$actual,
|
|
"Invoice number {$i} should be {$expected} but got {$actual}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test that invoice number initialization works
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_initializes_invoice_number_if_not_exists()
|
|
{
|
|
// Delete the setting
|
|
Setting::where('slug', 'invoice-number')->delete();
|
|
|
|
// Should create and return 1
|
|
$number = Invoice::makeNextInvoiceNumber();
|
|
$this->assertEquals(1, $number);
|
|
|
|
// Verify setting was created
|
|
$setting = Setting::where('slug', 'invoice-number')->first();
|
|
$this->assertNotNull($setting);
|
|
$this->assertEquals(1, $setting->int);
|
|
}
|
|
|
|
/**
|
|
* Test concurrent transaction commits
|
|
*
|
|
* @test
|
|
*/
|
|
public function it_handles_concurrent_transaction_commits()
|
|
{
|
|
$initialNumber = Invoice::getInvoiceNumber();
|
|
$results = [];
|
|
|
|
// Simulate 3 concurrent transactions
|
|
for ($i = 0; $i < 3; $i++) {
|
|
try {
|
|
DB::beginTransaction();
|
|
$number = Invoice::makeNextInvoiceNumber();
|
|
$results[] = $number;
|
|
DB::commit();
|
|
} catch (\Exception $e) {
|
|
DB::rollBack();
|
|
$this->fail('Transaction should not fail: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
// All should be unique and sequential
|
|
$this->assertCount(3, array_unique($results));
|
|
sort($results);
|
|
$this->assertEquals([$initialNumber + 1, $initialNumber + 2, $initialNumber + 3], $results);
|
|
}
|
|
}
|