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); } }