whisper-money/tests/Feature/OpenBanking/ConnectionAccountTest.php

234 lines
7.9 KiB
PHP

<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\AccountType;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
config([
'services.enablebanking.app_id' => 'test-app-id',
'services.enablebanking.private_key_path' => '/tmp/fake-key.pem',
'services.enablebanking.redirect_url' => 'https://example.com/callback',
]);
});
/**
* The manage-accounts surface is gated behind the ManageBankAccounts feature,
* which is only active for the admin user. Mark the created user as the admin so
* the gate lets the request through.
*/
function adminUser(): User
{
$user = User::factory()->onboarded()->create();
config(['mail.admin_email' => $user->email]);
return $user;
}
test('index renders the manage page with synced and available accounts', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
Account::factory()->for($user)->create([
'banking_connection_id' => $connection->id,
'external_account_id' => 'uid-1',
'currency_code' => 'EUR',
]);
Account::factory()->for($user)->create([
'banking_connection_id' => null,
'currency_code' => 'EUR',
]);
$this->actingAs($user)
->get(route('open-banking.connection-accounts.index', $connection))
->assertOk()
->assertInertia(fn ($page) => $page
->component('open-banking/manage-accounts')
->has('syncedAccounts', 1)
->has('availableAccounts', 1)
->where('discoveredAccounts', null)
);
});
test('refresh discovers bank accounts that are not yet synced', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
Account::factory()->for($user)->create([
'banking_connection_id' => $connection->id,
'external_account_id' => 'synced-uid',
]);
$this->mock(BankingProviderInterface::class, function ($mock) {
$mock->shouldReceive('getSession')->andReturn([
'status' => 'AUTHORIZED',
'accounts' => [
['uid' => 'synced-uid'],
['uid' => 'new-uid'],
],
]);
$mock->shouldReceive('getAccount')->with('new-uid')->andReturn([
'uid' => 'new-uid',
'account_id' => ['iban' => 'ES9999999999999999999999'],
'currency' => 'EUR',
'name' => 'Savings',
]);
});
$this->actingAs($user)
->get(route('open-banking.connection-accounts.index', [$connection, 'refresh' => 1]))
->assertOk()
->assertInertia(fn ($page) => $page
->has('discoveredAccounts', 1)
->where('discoveredAccounts.0.uid', 'new-uid')
->where('discoveredAccounts.0.name', 'Savings')
->where('discoveredAccounts.0.iban', 'ES9999999999999999999999')
);
});
test('map create adds a new synced account and dispatches a sync', function () {
Queue::fake();
$user = adminUser();
$connection = BankingConnection::factory()->create([
'user_id' => $user->id,
'aspsp_name' => 'Test Bank',
]);
$this->actingAs($user)
->post(route('open-banking.connection-accounts.map', $connection), [
'bank_account_uid' => 'new-uid',
'action' => 'create',
'name' => 'New Checking',
'currency' => 'EUR',
'iban' => 'ES1111111111111111111111',
])
->assertRedirect();
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'new-uid',
'name' => 'New Checking',
'currency_code' => 'EUR',
'iban' => 'ES1111111111111111111111',
]);
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('map link moves syncing to another account and unlinks the previous one', function () {
Queue::fake();
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$source = Account::factory()->for($user)->create([
'banking_connection_id' => $connection->id,
'external_account_id' => 'uid-1',
'iban' => 'ES2222222222222222222222',
'currency_code' => 'EUR',
]);
$target = Account::factory()->for($user)->create([
'banking_connection_id' => null,
'currency_code' => 'EUR',
'type' => AccountType::Checking->value,
]);
$this->actingAs($user)
->post(route('open-banking.connection-accounts.map', $connection), [
'bank_account_uid' => 'uid-1',
'action' => 'link',
'existing_account_id' => $target->id,
])
->assertRedirect();
expect($target->refresh())
->banking_connection_id->toBe($connection->id)
->external_account_id->toBe('uid-1')
->iban->toBe('ES2222222222222222222222')
->and($target->linked_at)->not->toBeNull();
expect($source->refresh())
->banking_connection_id->toBeNull()
->external_account_id->toBeNull();
});
test('unlink stops syncing but keeps the account and its transactions', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->for($user)->create([
'banking_connection_id' => $connection->id,
'external_account_id' => 'uid-1',
]);
$transaction = Transaction::factory()->for($account)->create(['user_id' => $user->id]);
$this->actingAs($user)
->post(route('open-banking.connection-accounts.unlink', [$connection, $account]))
->assertRedirect();
expect($account->refresh())
->banking_connection_id->toBeNull()
->external_account_id->toBeNull();
$this->assertDatabaseHas('accounts', ['id' => $account->id, 'deleted_at' => null]);
$this->assertDatabaseHas('transactions', ['id' => $transaction->id]);
});
test('index is forbidden for another user\'s connection', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create([
'user_id' => User::factory()->onboarded()->create()->id,
]);
$this->actingAs($user)
->get(route('open-banking.connection-accounts.index', $connection))
->assertForbidden();
});
test('unlink rejects an account that does not belong to the connection', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->for($user)->create([
'banking_connection_id' => null,
]);
$this->actingAs($user)
->post(route('open-banking.connection-accounts.unlink', [$connection, $account]))
->assertNotFound();
});
test('manage accounts is forbidden when the feature is disabled', function () {
$user = User::factory()->onboarded()->create();
config(['mail.admin_email' => 'someone-else@example.com']);
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->get(route('open-banking.connection-accounts.index', $connection))
->assertForbidden();
});
test('map link rejects a non-transactional target account', function () {
$user = adminUser();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
$loan = Account::factory()->for($user)->create([
'banking_connection_id' => null,
'currency_code' => 'EUR',
'type' => AccountType::Loan->value,
]);
$this->actingAs($user)
->post(route('open-banking.connection-accounts.map', $connection), [
'bank_account_uid' => 'uid-x',
'action' => 'link',
'existing_account_id' => $loan->id,
])
->assertStatus(422);
});