create(); $this->actingAs($user, 'web'); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, // 1 hour 'description' => 'Test payment', 'transaction_type_id' => 1, // Work 'creator_user_id' => $user->id, ]); $this->assertDatabaseHas('transactions', [ 'id' => $transaction->id, 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, ]); } /** * Test that transactions can be read (SELECT allowed) */ public function test_transactions_can_be_read() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 120, // 2 hours 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); $retrieved = Transaction::find($transaction->id); $this->assertNotNull($retrieved); $this->assertEquals(120, $retrieved->amount); $this->assertEquals('Test payment', $retrieved->description); } /** * Test that attempting to update transactions via Eloquent throws exception * * Note: This tests application-level protection. Database-level protection * (MySQL user permissions) should also prevent UPDATE at DB level. */ public function test_transactions_cannot_be_updated_via_eloquent() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, 'description' => 'Original description', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); $originalAmount = $transaction->amount; $originalDescription = $transaction->description; // Try to modify the transaction $transaction->amount = 120; $transaction->description = 'Modified description'; // If save() succeeds, verify data hasn't changed in database // If MySQL permissions are correctly set, save() should fail try { $transaction->save(); // If save succeeded, check if data actually changed $transaction->refresh(); // Transaction should remain unchanged (either save failed silently or DB rejected UPDATE) $this->assertEquals($originalAmount, $transaction->amount, 'Transaction amount should not be modifiable'); $this->assertEquals($originalDescription, $transaction->description, 'Transaction description should not be modifiable'); } catch (\Exception $e) { // Expected: UPDATE permission denied $this->assertTrue(true, 'Transaction update correctly prevented'); } } /** * Test that attempting to delete transactions via Eloquent throws exception * * Note: This tests application-level protection. Database-level protection * (MySQL user permissions) should also prevent DELETE at DB level. */ public function test_transactions_cannot_be_deleted_via_eloquent() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); $transactionId = $transaction->id; // Try to delete the transaction try { $transaction->delete(); // If delete succeeded, verify transaction still exists $this->assertDatabaseHas('transactions', ['id' => $transactionId], 'Transaction should not be deletable'); } catch (\Exception $e) { // Expected: DELETE permission denied $this->assertTrue(true, 'Transaction deletion correctly prevented'); } } /** * Test that raw SQL UPDATE is prevented by database permissions * * This test verifies the MySQL user permission restrictions. */ public function test_raw_sql_update_is_prevented() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, 'description' => 'Original description', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); $originalAmount = $transaction->amount; // Try to update via raw SQL try { DB::statement('UPDATE transactions SET amount = ? WHERE id = ?', [120, $transaction->id]); // If update succeeded, verify data hasn't changed $transaction->refresh(); $this->assertEquals($originalAmount, $transaction->amount, 'Raw SQL UPDATE should not modify transaction'); } catch (\Exception $e) { // Expected behavior: Permission denied $this->assertStringContainsString('UPDATE command denied', $e->getMessage()); } } /** * Test that raw SQL DELETE is prevented by database permissions */ public function test_raw_sql_delete_is_prevented() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $transaction = Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => 60, 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); $transactionId = $transaction->id; // Try to delete via raw SQL try { DB::statement('DELETE FROM transactions WHERE id = ?', [$transactionId]); // If delete succeeded, verify transaction still exists $this->assertDatabaseHas('transactions', ['id' => $transactionId], 'Raw SQL DELETE should not remove transaction'); } catch (\Exception $e) { // Expected behavior: Permission denied $this->assertStringContainsString('DELETE command denied', $e->getMessage()); } } // ========================================== // BALANCE CALCULATION INTEGRITY TESTS // ========================================== /** * Test that balance calculations are correct for single transaction */ public function test_balance_calculation_single_transaction() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $account1 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user1->id, ]); $account2 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user2->id, ]); // Initial balances should be 0 $this->assertEquals(0, $account1->balance); $this->assertEquals(0, $account2->balance); // Create transaction: user1 pays user2 60 minutes Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account2->id, 'amount' => 60, 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); // Clear cache to force recalculation \Cache::forget("account_balance_{$account1->id}"); \Cache::forget("account_balance_{$account2->id}"); // Refresh models $account1 = $account1->fresh(); $account2 = $account2->fresh(); // user1 should have -60, user2 should have +60 $this->assertEquals(-60, $account1->balance); $this->assertEquals(60, $account2->balance); } /** * Test that balance calculations are correct for multiple transactions */ public function test_balance_calculation_multiple_transactions() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $account1 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user1->id, ]); $account2 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user2->id, ]); // Transaction 1: user1 pays user2 60 minutes Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account2->id, 'amount' => 60, 'description' => 'Payment 1', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); // Transaction 2: user2 pays user1 30 minutes Transaction::create([ 'from_account_id' => $account2->id, 'to_account_id' => $account1->id, 'amount' => 30, 'description' => 'Payment 2', 'transaction_type_id' => 1, 'creator_user_id' => $user2->id, ]); // Transaction 3: user1 pays user2 15 minutes Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account2->id, 'amount' => 15, 'description' => 'Payment 3', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); // Clear cache \Cache::forget("account_balance_{$account1->id}"); \Cache::forget("account_balance_{$account2->id}"); $account1 = $account1->fresh(); $account2 = $account2->fresh(); // user1: -60 + 30 - 15 = -45 // user2: +60 - 30 + 15 = +45 $this->assertEquals(-45, $account1->balance); $this->assertEquals(45, $account2->balance); } /** * Test that balance calculations handle concurrent transactions correctly */ public function test_balance_calculation_with_concurrent_transactions() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $user3 = User::factory()->create(); $account1 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user1->id, ]); $account2 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user2->id, ]); $account3 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user3->id, ]); // Simulate multiple transactions happening "simultaneously" DB::transaction(function () use ($account1, $account2, $account3, $user1, $user2, $user3) { // Multiple transfers from account1 Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account2->id, 'amount' => 30, 'description' => 'Concurrent payment 1', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account3->id, 'amount' => 20, 'description' => 'Concurrent payment 2', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); Transaction::create([ 'from_account_id' => $account2->id, 'to_account_id' => $account1->id, 'amount' => 10, 'description' => 'Concurrent payment 3', 'transaction_type_id' => 1, 'creator_user_id' => $user2->id, ]); }); // Clear cache \Cache::forget("account_balance_{$account1->id}"); \Cache::forget("account_balance_{$account2->id}"); \Cache::forget("account_balance_{$account3->id}"); $account1 = $account1->fresh(); $account2 = $account2->fresh(); $account3 = $account3->fresh(); // account1: -30 - 20 + 10 = -40 // account2: +30 - 10 = +20 // account3: +20 = +20 $this->assertEquals(-40, $account1->balance); $this->assertEquals(20, $account2->balance); $this->assertEquals(20, $account3->balance); // Sum of all balances should be 0 (zero-sum system) $totalBalance = $account1->balance + $account2->balance + $account3->balance; $this->assertEquals(0, $totalBalance, 'Total balance in system should always be zero'); } // ========================================== // TRANSACTION VALIDATION TESTS // ========================================== /** * Test that transactions require valid from_account_id */ public function test_transaction_requires_valid_from_account() { $user = User::factory()->create(); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); $this->expectException(\Exception::class); Transaction::create([ 'from_account_id' => 99999, // Non-existent account 'to_account_id' => $toAccount->id, 'amount' => 60, 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); } /** * Test that transactions require valid to_account_id */ public function test_transaction_requires_valid_to_account() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $this->expectException(\Exception::class); Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => 99999, // Non-existent account 'amount' => 60, 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); } /** * Test that transactions require positive amount */ public function test_transaction_requires_positive_amount() { $user = User::factory()->create(); $fromAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $toAccount = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => User::factory()->create()->id, ]); // Negative amounts should be rejected $this->expectException(\Exception::class); Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => -60, // Negative amount 'description' => 'Test payment', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); } /** * Test that from_account and to_account must be different */ public function test_transaction_requires_different_accounts() { $user = User::factory()->create(); $account = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); $this->expectException(\Exception::class); Transaction::create([ 'from_account_id' => $account->id, 'to_account_id' => $account->id, // Same as from_account 'amount' => 60, 'description' => 'Self-payment test', 'transaction_type_id' => 1, 'creator_user_id' => $user->id, ]); } // ========================================== // ZERO-SUM INTEGRITY TESTS // ========================================== /** * Test that the system maintains zero-sum integrity * * In a timebanking system, the sum of all account balances * should always equal zero (money is neither created nor destroyed * except through special currency creation/removal transactions). */ public function test_system_maintains_zero_sum_integrity() { // Create multiple users and accounts $users = User::factory()->count(5)->create(); $accounts = $users->map(function ($user) { return Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user->id, ]); }); // Create random transactions between accounts for ($i = 0; $i < 10; $i++) { $fromAccount = $accounts->random(); $toAccount = $accounts->where('id', '!=', $fromAccount->id)->random(); Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => rand(10, 100), 'description' => "Random transaction {$i}", 'transaction_type_id' => 1, 'creator_user_id' => $users->random()->id, ]); } // Clear all balance caches foreach ($accounts as $account) { \Cache::forget("account_balance_{$account->id}"); } // Calculate sum of all balances $totalBalance = $accounts->sum(function ($account) { return $account->fresh()->balance; }); // Should be exactly 0 (zero-sum system) $this->assertEquals(0, $totalBalance, 'System should maintain zero-sum integrity: sum of all balances must be 0'); } /** * Test that transaction creation maintains database consistency */ public function test_transaction_creation_maintains_consistency() { $user1 = User::factory()->create(); $user2 = User::factory()->create(); $account1 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user1->id, ]); $account2 = Account::factory()->create([ 'accountable_type' => User::class, 'accountable_id' => $user2->id, ]); $transactionCountBefore = Transaction::count(); // Create transaction in database transaction DB::transaction(function () use ($account1, $account2, $user1) { Transaction::create([ 'from_account_id' => $account1->id, 'to_account_id' => $account2->id, 'amount' => 60, 'description' => 'Consistency test', 'transaction_type_id' => 1, 'creator_user_id' => $user1->id, ]); }); $transactionCountAfter = Transaction::count(); // Verify transaction was created $this->assertEquals($transactionCountBefore + 1, $transactionCountAfter); // Verify transaction is retrievable $transaction = Transaction::latest()->first(); $this->assertNotNull($transaction); $this->assertEquals($account1->id, $transaction->from_account_id); $this->assertEquals($account2->id, $transaction->to_account_id); $this->assertEquals(60, $transaction->amount); } }