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