test(open-banking): e2e coverage for Enable Banking connection flows (#509)
## Why Bank connection via Enable Banking is a critical flow that must keep working in **both onboarding and settings**, including reconnecting an expired consent. This adds regression coverage so it never silently breaks. ## What Two complementary layers: ### 1. CI regression — mocked Pest browser tests `tests/Browser/BankConnectionFlowTest.php` drives the full UI → `authorize` → `callback` → account mapping → sync flow for all three scenarios, with the banking provider faked (`tests/Support/FakeBankingProvider.php`). Deterministic, no external calls. - ✅ connect from onboarding (accounts auto-created) - ✅ connect from settings (+ account mapping) - ✅ reconnect an expired connection (re-matches accounts by IBAN) **Runs on CI** in the existing `browser-tests` job (distributed across shards automatically as a new test; `shards.json` timing entry will be added next time `update-browser-shards` runs). ### 2. Live-sandbox verification — standalone Playwright script `tests/Browser/live/connect-bank.mjs` runs the same three flows against the **real Enable Banking sandbox** (Banco de Sabadell + BBVA) via the dev server, for on-demand / nightly checks. Backed by a local-only helper command `app/Console/Commands/E2eBankingFixtureCommand.php`. **Does not run on CI** (needs the dev server, live sandbox, and secrets). See `tests/Browser/live/README.md`. #### Why the live flow can't be a normal Pest test Enable Banking only redirects to the fixed registered URI (`https://whisper.money.local/open-banking/callback`). A `RefreshDatabase` Pest test runs an ephemeral server on a random port with a transactional DB the external redirect can never reach. The script instead drives the persistent dev server, captures the `code`+`state` the bank redirects with, and replays the callback against the dev server (the callback is stateless — keyed by `state_token`). ## Verification - Mocked Pest tests: **3 passing**. - Live script: **all 3 scenarios PASS** against the real sandbox (Sabadell → 5 accounts + 328 tx synced; BBVA onboarding → 7 accounts; expired → reconnect refreshes `valid_until` and retains accounts). - `pint --test`, eslint, prettier clean. > Note: while verifying locally, the dev DB was missing the `state_token` migration (`2026_06_05_185212`), which made `/open-banking/authorize` 500. Worth confirming it's applied in other environments.
This commit is contained in:
parent
6486908716
commit
c4987b7f05
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Support command for the live Enable Banking e2e script
|
||||
* (tests/Browser/live/connect-bank.mjs). It seeds deterministic users, inspects the
|
||||
* resulting connection, and can force a connection into the expired state so the
|
||||
* reconnect flow can be exercised. Output is JSON so the script can parse it.
|
||||
*
|
||||
* Restricted to local/testing environments — it creates passwordful users and fake
|
||||
* subscriptions and must never run against production data.
|
||||
*/
|
||||
class E2eBankingFixtureCommand extends Command
|
||||
{
|
||||
protected $signature = 'e2e:banking-fixture {action : seed|inspect|expire} {email? : target user email for inspect/expire}';
|
||||
|
||||
protected $description = 'Seed/inspect fixtures for the live Enable Banking e2e script (local only).';
|
||||
|
||||
private const ONBOARDING_EMAIL = 'e2e-onboarding@example.test';
|
||||
|
||||
private const SETTINGS_EMAIL = 'e2e-settings@example.test';
|
||||
|
||||
private const PASSWORD = 'password';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
$this->error('e2e:banking-fixture is only available in local/testing environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return match ($this->argument('action')) {
|
||||
'seed' => $this->seed(),
|
||||
'inspect' => $this->inspect(),
|
||||
'expire' => $this->expire(),
|
||||
default => $this->invalidAction(),
|
||||
};
|
||||
}
|
||||
|
||||
private function seed(): int
|
||||
{
|
||||
$onboarding = $this->resetUser(self::ONBOARDING_EMAIL, onboarded: false, pro: false);
|
||||
$settings = $this->resetUser(self::SETTINGS_EMAIL, onboarded: true, pro: true);
|
||||
|
||||
$this->line((string) json_encode([
|
||||
'onboarding' => ['email' => $onboarding->email, 'password' => self::PASSWORD],
|
||||
'settings' => ['email' => $settings->email, 'password' => self::PASSWORD],
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function inspect(): int
|
||||
{
|
||||
$connection = $this->latestConnection();
|
||||
|
||||
if (! $connection) {
|
||||
$this->line((string) json_encode(['connection' => null]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$accountIds = $connection->accounts()->pluck('id');
|
||||
|
||||
$this->line((string) json_encode([
|
||||
'connection' => [
|
||||
'id' => $connection->id,
|
||||
'status' => $connection->status->value,
|
||||
'aspsp_name' => $connection->aspsp_name,
|
||||
'session_id' => $connection->session_id !== null,
|
||||
'valid_until' => $connection->valid_until?->toIso8601String(),
|
||||
'accounts_count' => $accountIds->count(),
|
||||
'accounts_without_external_id' => $connection->accounts()->whereNull('external_account_id')->count(),
|
||||
'transactions_count' => Transaction::whereIn('account_id', $accountIds)->count(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function expire(): int
|
||||
{
|
||||
$connection = $this->latestConnection();
|
||||
|
||||
if (! $connection) {
|
||||
$this->error('No connection found for '.$this->argument('email'));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::Expired,
|
||||
'valid_until' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->line((string) json_encode(['expired' => $connection->id]));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resetUser(string $email, bool $onboarded, bool $pro): User
|
||||
{
|
||||
// Reuse the user across runs (the model soft-deletes, so recreating the same
|
||||
// email would collide). Clear everything the flows produce so each run starts
|
||||
// from a clean slate.
|
||||
$user = User::withTrashed()->firstWhere('email', $email)
|
||||
?? User::factory()->create(['email' => $email]);
|
||||
|
||||
$user->accounts()->forceDelete();
|
||||
$user->bankingConnections()->forceDelete();
|
||||
$user->subscriptions()->delete();
|
||||
|
||||
$user->restore();
|
||||
$user->forceFill([
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make(self::PASSWORD),
|
||||
'onboarded_at' => $onboarded ? now() : null,
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
|
||||
if ($pro) {
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_e2e_'.$user->id,
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_e2e',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function latestConnection(): ?BankingConnection
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user->bankingConnections()->latest()->first();
|
||||
}
|
||||
|
||||
private function invalidAction(): int
|
||||
{
|
||||
$this->error('Unknown action. Use one of: seed, inspect, expire.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
|
@ -74,6 +74,10 @@
|
|||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"e2e:banking": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"node tests/Browser/live/connect-bank.mjs"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\Account;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
use Tests\Support\FakeBankingProvider;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
/**
|
||||
* End-to-end coverage of the Enable Banking connection flow, with the provider faked
|
||||
* so it runs deterministically in CI. The live-sandbox equivalent of these flows lives
|
||||
* in tests/Browser/live/connect-bank.mjs and runs on demand against the real sandbox.
|
||||
*
|
||||
* @see FakeBankingProvider
|
||||
*/
|
||||
beforeEach(function () {
|
||||
// Bypass the Stripe-backed subscription gate; gating itself is covered elsewhere.
|
||||
config(['subscriptions.enabled' => false]);
|
||||
|
||||
$this->fakeProvider = new FakeBankingProvider;
|
||||
app()->instance(BankingProviderInterface::class, $this->fakeProvider);
|
||||
});
|
||||
|
||||
it('connects a bank during onboarding', function () {
|
||||
$user = User::factory()->notOnboarded()->create([
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/onboarding');
|
||||
|
||||
$page->assertSee('Welcome to')
|
||||
->click("Let's Get Started")
|
||||
->waitForText('Account Types', 5)
|
||||
->click('Create Your First Account')
|
||||
->waitForText('How would you like to set up this account?', 5)
|
||||
->click('Connected')
|
||||
->click('Continue')
|
||||
->waitForText('Connect Your Bank', 5)
|
||||
->click('[role="combobox"]')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Spain")')
|
||||
->wait(0.3)
|
||||
->click('button:has-text("Continue")')
|
||||
->waitForText('Banco de Sabadell', 5)
|
||||
->click('button:has-text("Banco de Sabadell")')
|
||||
->click('button:has-text("Continue")')
|
||||
->waitForText('You will be redirected', 5)
|
||||
->click('button:has-text("Connect")')
|
||||
->wait(3);
|
||||
|
||||
// The faked provider redirects straight to our callback, which auto-creates the
|
||||
// accounts for a not-yet-onboarded user and marks the connection active.
|
||||
$connection = $user->bankingConnections()->sole();
|
||||
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active)
|
||||
->and($connection->aspsp_name)->toBe('Banco de Sabadell')
|
||||
->and($connection->accounts()->count())->toBe(2);
|
||||
|
||||
$page->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('connects a bank from settings and maps accounts', function () {
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'onboarded_at' => now(),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/connections');
|
||||
|
||||
$page->assertSee('Bank Connections')
|
||||
->click('Connect Bank')
|
||||
->waitForText('Select the country', 5)
|
||||
->click('[role="dialog"] [role="combobox"]')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Spain")')
|
||||
->wait(0.3)
|
||||
->click('button:has-text("Continue")')
|
||||
->waitForText('Banco de Sabadell', 5)
|
||||
->click('[role="dialog"] button:has-text("Banco de Sabadell")')
|
||||
->click('[role="dialog"] button:has-text("Continue")')
|
||||
->waitForText('You will be redirected', 5)
|
||||
->click('[role="dialog"] button:has-text("Connect")')
|
||||
->wait(3);
|
||||
|
||||
// An onboarded user lands on the account-mapping screen (status awaiting_mapping).
|
||||
$connection = $user->bankingConnections()->sole();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping)
|
||||
->and($connection->pending_accounts_data)->toHaveCount(2);
|
||||
|
||||
$page->assertPathBeginsWith('/open-banking/connections/')
|
||||
->assertSee('Map Bank Accounts')
|
||||
->click('button:has-text("Save & Sync")')
|
||||
->wait(3);
|
||||
|
||||
expect($connection->fresh()->status)->toBe(BankingConnectionStatus::Active)
|
||||
->and($connection->accounts()->count())->toBe(2);
|
||||
|
||||
$page->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('shows the connected confirmation when the session is lost on return', function () {
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'onboarded_at' => now(),
|
||||
]);
|
||||
|
||||
// A pending connection mid-authorization, identified by its state token.
|
||||
$connection = BankingConnection::factory()->pending()->for($user)->create([
|
||||
'provider' => 'enablebanking',
|
||||
'aspsp_name' => 'Banco de Sabadell',
|
||||
'aspsp_country' => 'ES',
|
||||
'state_token' => 'session-lost-token',
|
||||
]);
|
||||
|
||||
// Hit the callback WITHOUT acting as the user — this is the iOS-PWA case where the
|
||||
// bank redirect lands in a browser that has no app session.
|
||||
$page = visit('/open-banking/callback?code=fake&state=session-lost-token');
|
||||
|
||||
$page->assertSee('Bank account connected')
|
||||
->assertSee('go back to the app')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
// The connection is still finalized server-side (resolved from the state token).
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping)
|
||||
->and($connection->session_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('reconnects an expired connection from settings', function () {
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'onboarded_at' => now(),
|
||||
]);
|
||||
|
||||
$connection = BankingConnection::factory()->expired()->for($user)->create([
|
||||
'provider' => 'enablebanking',
|
||||
'aspsp_name' => 'Banco de Sabadell',
|
||||
'aspsp_country' => 'ES',
|
||||
]);
|
||||
|
||||
// Existing accounts carry the IBANs the faked provider returns, so the reconnect
|
||||
// re-matches them by IBAN and refreshes their external ids instead of duplicating.
|
||||
foreach (['ES1800810602610001111120', 'ES6200810602620003333338'] as $iban) {
|
||||
Account::factory()->for($user)->create([
|
||||
'banking_connection_id' => $connection->id,
|
||||
'iban' => $iban,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
}
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/connections');
|
||||
|
||||
$page->assertSee('Banco de Sabadell')
|
||||
->assertSee('Expired')
|
||||
->click('Reconnect')
|
||||
->wait(3);
|
||||
|
||||
$connection->refresh();
|
||||
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active)
|
||||
->and($connection->valid_until->isFuture())->toBeTrue()
|
||||
->and($connection->accounts()->count())->toBe(2)
|
||||
->and($connection->accounts()->whereNull('external_account_id')->count())->toBe(0);
|
||||
|
||||
$page->assertNoJavascriptErrors();
|
||||
});
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
failure-*.png
|
||||
videos/
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# Live Enable Banking e2e check
|
||||
|
||||
`connect-bank.mjs` drives the **real Enable Banking sandbox** end-to-end against the
|
||||
running dev server, covering the flows we must never regress:
|
||||
|
||||
1. **Settings → connect** a bank (Banco de Sabadell), map accounts, and sync transactions.
|
||||
2. **Onboarding → connect** a bank (BBVA) with auto-created accounts.
|
||||
3. **Settings → reconnect** an expired connection.
|
||||
4. **Session lost on return** — connect, then drop the app session before the bank
|
||||
redirect lands, and confirm the user still sees the "connected — go back to your
|
||||
app" screen (the iOS-PWA / Safari case) instead of being bounced to login.
|
||||
|
||||
It uses the sandbox test credentials (`user1` / `1234`, OTP `012345`).
|
||||
|
||||
## Why a standalone script and not a Pest test
|
||||
|
||||
Enable Banking only redirects to the **fixed registered redirect URI**
|
||||
(`https://whisper.money.local/open-banking/callback`). A Pest browser test runs an
|
||||
ephemeral server on a random port with a `RefreshDatabase` transactional database that
|
||||
the external redirect can never reach, so the live flow cannot complete inside one.
|
||||
|
||||
This script instead drives the **persistent dev server**, lets the bank redirect to the
|
||||
registered host (which is not served locally), captures the `code`+`state` it carries,
|
||||
and replays the callback against the dev server. The callback is stateless — it resolves
|
||||
the connection from the `state_token` — so the replay finalizes the connection correctly.
|
||||
|
||||
For CI regression coverage of the same three flows with the provider faked, see
|
||||
`tests/Browser/BankConnectionFlowTest.php`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `composer run dev` running. Note the app port it prints (default `8921`).
|
||||
- Valid `ENABLEBANKING_*` config and private key (the same config the app uses), with
|
||||
`ENABLEBANKING_REDIRECT_URL` pointing at the registered `whisper.money.local` callback.
|
||||
- Pending migrations applied (`php artisan migrate`).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# one-liner (auto-detects the running dev server port)
|
||||
composer e2e:banking
|
||||
|
||||
# or directly
|
||||
node tests/Browser/live/connect-bank.mjs
|
||||
|
||||
# watch it run in a headed browser / override the base url
|
||||
HEADLESS=0 node tests/Browser/live/connect-bank.mjs
|
||||
APP_BASE_URL=http://127.0.0.1:8921 node tests/Browser/live/connect-bank.mjs
|
||||
```
|
||||
|
||||
The script seeds its own users via `php artisan e2e:banking-fixture seed`
|
||||
(local/testing only), reports `PASS`/`FAIL` per scenario, and exits non-zero on failure.
|
||||
|
||||
### Videos & screenshots
|
||||
|
||||
Everything is written to `tests/Browser/live/videos/` (cleared at the start of each run,
|
||||
git-ignored):
|
||||
|
||||
- `<scenario>.mp4` — a recording of the whole flow. A visible cursor follows the mouse
|
||||
and pulses red on every click so you can track what's happening. Encoded as mp4
|
||||
(H.264) so it plays in QuickTime / Preview / browsers. Needs `ffmpeg`; without it the
|
||||
raw `.webm` is kept instead.
|
||||
- `<scenario>.png` — a still of the final screen (guaranteed clear, independent of video
|
||||
timing).
|
||||
- `failure-<scenario>.png` — a full-page screenshot, written only when a scenario fails.
|
||||
|
||||
Scenarios: `settings-connect`, `onboarding-connect`, `reconnect-expired`,
|
||||
`session-lost-return`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The Enable Banking and bank sandbox pages are driven in English; the script forces an
|
||||
`en-US` browser locale so the button labels match.
|
||||
- The transaction sync runs on the default queue (the dev worker only drains `emails`),
|
||||
so the script triggers `php artisan banking:sync --connection=<id> --sync` to verify
|
||||
transactions pull through.
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
// Live Enable Banking end-to-end check.
|
||||
//
|
||||
// Drives the REAL Enable Banking sandbox (Banco de Sabadell + BBVA) through the
|
||||
// running dev server for the three flows we must never regress:
|
||||
// 1. Connect a bank from settings (+ account mapping + sync).
|
||||
// 2. Connect a bank during onboarding.
|
||||
// 3. Reconnect an expired connection.
|
||||
//
|
||||
// Why this is a standalone script and not a Pest browser test: Enable Banking only
|
||||
// redirects to the fixed registered URI (https://whisper.money.local/open-banking/
|
||||
// callback). A RefreshDatabase Pest test runs an ephemeral server on a random port
|
||||
// with a transactional DB the external redirect can never reach. So we drive the
|
||||
// persistent dev server, let the bank redirect to the registered host (which is not
|
||||
// served locally), capture the `code`+`state` it carries, and replay the callback
|
||||
// against the dev server (the callback is stateless — keyed by state_token).
|
||||
//
|
||||
// Prerequisites:
|
||||
// - `composer run dev` running (APP at http://127.0.0.1:<port>, default 8921).
|
||||
// - Valid ENABLEBANKING_* config + key (the same config the app uses).
|
||||
//
|
||||
// Usage:
|
||||
// APP_BASE_URL=http://127.0.0.1:8921 node tests/Browser/live/connect-bank.mjs
|
||||
// HEADLESS=0 node tests/Browser/live/connect-bank.mjs # watch it run
|
||||
//
|
||||
// See tests/Browser/live/README.md for details.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import process from 'node:process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const VIDEO_DIR = 'tests/Browser/live/videos';
|
||||
|
||||
// `composer run dev` serves on a random port, so auto-detect the running
|
||||
// `artisan serve --port=N` unless APP_BASE_URL is given explicitly.
|
||||
function detectBaseUrl() {
|
||||
if (process.env.APP_BASE_URL) {
|
||||
return process.env.APP_BASE_URL.replace(/\/$/, '');
|
||||
}
|
||||
try {
|
||||
const port = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
"ps ax -o command | grep -oE 'artisan serve --port=[0-9]+' | grep -oE '[0-9]+$' | head -1",
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
if (port) {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the default
|
||||
}
|
||||
return 'http://127.0.0.1:8921';
|
||||
}
|
||||
|
||||
const BASE_URL = detectBaseUrl();
|
||||
const HEADLESS = process.env.HEADLESS !== '0';
|
||||
const OTP = '012345';
|
||||
const SABADELL_USER = 'user1';
|
||||
const SABADELL_PASS = '1234';
|
||||
const BBVA_USER = 'user1';
|
||||
const BBVA_PASS = '1234';
|
||||
|
||||
function artisan(args) {
|
||||
const out = execFileSync('php', ['artisan', ...args], { encoding: 'utf8' });
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function artisanJson(args) {
|
||||
const out = artisan(args);
|
||||
const start = out.indexOf('{');
|
||||
return JSON.parse(out.slice(start));
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(`Assertion failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(` ${message}\n`);
|
||||
}
|
||||
|
||||
// Capture the bank's redirect to the registered (non-local) callback host and replay
|
||||
// it against the dev server so the connection is finalized there.
|
||||
function trackCallback(page) {
|
||||
const state = { url: null };
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (
|
||||
url.includes('/open-banking/callback') &&
|
||||
url.includes('code=') &&
|
||||
!url.startsWith(BASE_URL)
|
||||
) {
|
||||
state.url = url;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function replayCallback(page, captured) {
|
||||
assert(
|
||||
captured.url,
|
||||
'expected the bank to redirect to the callback with a code',
|
||||
);
|
||||
const replay = captured.url.replace(/^https?:\/\/[^/]+/, BASE_URL);
|
||||
captured.url = null;
|
||||
await page.goto(replay, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
async function login(page, email, password) {
|
||||
// Each scenario starts from a clean session (the prior user is still logged in
|
||||
// otherwise, and /login would bounce straight to /dashboard).
|
||||
await page.context().clearCookies();
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('textbox', { name: 'Email address' }).fill(email);
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill(password);
|
||||
await page.locator('[data-test="login-button"]').click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
const EB_CONTINUE = /Continue authentication|Continuar autenticación/;
|
||||
|
||||
async function driveSabadell(page) {
|
||||
await page.getByRole('button', { name: EB_CONTINUE }).click();
|
||||
await page.waitForTimeout(4000);
|
||||
await page.getByPlaceholder('Ej: 47587441A').fill(SABADELL_USER);
|
||||
await page.getByPlaceholder('Ej: 123456').first().fill(SABADELL_PASS);
|
||||
await page.getByRole('button', { name: 'Entrar' }).click();
|
||||
await page.waitForTimeout(7000);
|
||||
// Consent + SCA: re-enter password, then the 6-digit OTP via the on-screen keypad.
|
||||
await page.getByPlaceholder('Ej: 123456').first().fill(SABADELL_PASS);
|
||||
await page.waitForTimeout(400);
|
||||
for (const digit of OTP.split('')) {
|
||||
await page.getByText(digit, { exact: true }).last().click();
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByRole('button', { name: /Sign|Firmar/ }).click();
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
|
||||
async function driveBbva(page) {
|
||||
await page.getByRole('button', { name: EB_CONTINUE }).click();
|
||||
await page.waitForTimeout(5000);
|
||||
const textboxes = await page.getByRole('textbox').all();
|
||||
await textboxes[0].fill(BBVA_USER);
|
||||
await textboxes[1].fill(BBVA_PASS);
|
||||
await page.getByRole('button', { name: 'Submit' }).first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
await page.getByRole('textbox', { name: 'SMS Code' }).fill(OTP);
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await page.waitForTimeout(10000);
|
||||
}
|
||||
|
||||
async function selectBankInModal(page, bankName) {
|
||||
await page.locator('[role="dialog"] [role="combobox"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByRole('option', { name: 'Spain' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('[role="dialog"] button:has-text("Continue")').click();
|
||||
await page.waitForTimeout(2800);
|
||||
await page
|
||||
.locator(`[role="dialog"] button:has-text("${bankName}")`)
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('[role="dialog"] button:has-text("Continue")').click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.locator('[role="dialog"] button:has-text("Connect")').click();
|
||||
await page.waitForTimeout(6000);
|
||||
}
|
||||
|
||||
async function scenarioSettingsConnect(page, settings) {
|
||||
log('Settings → connect Banco de Sabadell');
|
||||
const captured = trackCallback(page);
|
||||
await login(page, settings.email, settings.password);
|
||||
await page.goto(`${BASE_URL}/settings/connections`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Connect Bank' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
await selectBankInModal(page, 'Banco de Sabadell');
|
||||
await driveSabadell(page);
|
||||
await replayCallback(page, captured);
|
||||
|
||||
// Onboarded user lands on account mapping; accept the defaults and sync.
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: 'Save & Sync' }).click();
|
||||
// Lands back on the connections list; wait for the connection to render.
|
||||
await page
|
||||
.getByText('Banco de Sabadell')
|
||||
.first()
|
||||
.waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const mapped = artisanJson([
|
||||
'e2e:banking-fixture',
|
||||
'inspect',
|
||||
settings.email,
|
||||
]).connection;
|
||||
assert(mapped, 'settings connection exists');
|
||||
assert(
|
||||
mapped.status === 'active',
|
||||
`settings status active (got ${mapped?.status})`,
|
||||
);
|
||||
assert(mapped.accounts_count > 0, 'settings accounts created');
|
||||
|
||||
// The sync job runs on the default queue (the dev worker only drains "emails"),
|
||||
// so drive it synchronously to confirm transactions pull from the live sandbox.
|
||||
artisan(['banking:sync', '--connection=' + mapped.id, '--sync']);
|
||||
|
||||
const { connection } = artisanJson([
|
||||
'e2e:banking-fixture',
|
||||
'inspect',
|
||||
settings.email,
|
||||
]);
|
||||
assert(connection.transactions_count > 0, 'settings transactions synced');
|
||||
log(
|
||||
`✓ active · ${connection.accounts_count} accounts · ${connection.transactions_count} transactions`,
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioOnboardingConnect(page, onboarding) {
|
||||
log('Onboarding → connect BBVA');
|
||||
const captured = trackCallback(page);
|
||||
await login(page, onboarding.email, onboarding.password);
|
||||
await page.goto(`${BASE_URL}/onboarding`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.getByRole('button', { name: "Let's Get Started" }).click();
|
||||
await page.waitForTimeout(800);
|
||||
await page
|
||||
.getByRole('button', { name: 'Create Your First Account' })
|
||||
.click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.getByRole('button', { name: /Connected/ }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
// Inline (non-modal) connect on the onboarding step.
|
||||
await page.getByRole('combobox').last().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.getByRole('option', { name: 'Spain' }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForTimeout(2800);
|
||||
await page.getByRole('button', { name: /^BBVA$/ }).click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForTimeout(800);
|
||||
await page.getByRole('button', { name: 'Connect' }).click();
|
||||
await page.waitForTimeout(6000);
|
||||
await driveBbva(page);
|
||||
await replayCallback(page, captured);
|
||||
|
||||
// The onboarding step hydrates its account list from the client store after
|
||||
// navigation; wait for it to render so we assert the UI (not just the DB) and the
|
||||
// recording ends on the real result rather than a mid-load frame.
|
||||
await page.getByText('Your Accounts').waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const { connection } = artisanJson([
|
||||
'e2e:banking-fixture',
|
||||
'inspect',
|
||||
onboarding.email,
|
||||
]);
|
||||
assert(connection, 'onboarding connection exists');
|
||||
assert(
|
||||
connection.status === 'active',
|
||||
`onboarding status active (got ${connection?.status})`,
|
||||
);
|
||||
assert(connection.accounts_count > 0, 'onboarding accounts auto-created');
|
||||
log(`✓ active · ${connection.accounts_count} accounts`);
|
||||
}
|
||||
|
||||
async function scenarioReconnect(page, settings) {
|
||||
log('Settings → reconnect an expired connection');
|
||||
artisanJson(['e2e:banking-fixture', 'expire', settings.email]);
|
||||
|
||||
const captured = trackCallback(page);
|
||||
await login(page, settings.email, settings.password);
|
||||
await page.goto(`${BASE_URL}/settings/connections`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Reconnect' }).first().click();
|
||||
await page.waitForTimeout(6000);
|
||||
await driveSabadell(page);
|
||||
await replayCallback(page, captured);
|
||||
|
||||
// Back on the connections list with the connection live again.
|
||||
await page
|
||||
.getByText('Banco de Sabadell')
|
||||
.first()
|
||||
.waitFor({ timeout: 15000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const { connection } = artisanJson([
|
||||
'e2e:banking-fixture',
|
||||
'inspect',
|
||||
settings.email,
|
||||
]);
|
||||
assert(connection, 'reconnect connection exists');
|
||||
assert(
|
||||
connection.status === 'active',
|
||||
`reconnect status active (got ${connection?.status})`,
|
||||
);
|
||||
assert(
|
||||
new Date(connection.valid_until) > new Date(),
|
||||
'reconnect refreshed valid_until',
|
||||
);
|
||||
assert(
|
||||
connection.accounts_without_external_id === 0,
|
||||
'reconnect refreshed all account ids',
|
||||
);
|
||||
log(
|
||||
`✓ active · valid until ${connection.valid_until} · ${connection.accounts_count} accounts retained`,
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioSessionLost(page, settings) {
|
||||
log('Settings → connect, but the app session is lost on return');
|
||||
const captured = trackCallback(page);
|
||||
await login(page, settings.email, settings.password);
|
||||
await page.goto(`${BASE_URL}/settings/connections`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.getByRole('button', { name: 'Connect Bank' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
// Sabadell is already connected for this user from an earlier scenario; use BBVA.
|
||||
await selectBankInModal(page, 'BBVA');
|
||||
await driveBbva(page);
|
||||
|
||||
// Simulate the app session being lost while the user was away at the bank (e.g. an
|
||||
// iOS PWA hands the redirect to Safari, where there is no session). The callback is
|
||||
// therefore unauthenticated on return.
|
||||
await page.context().clearCookies();
|
||||
await replayCallback(page, captured);
|
||||
|
||||
// They must see the standalone "connected — go back to your app" confirmation
|
||||
// rather than being bounced to the login screen. This screen renders late in the
|
||||
// flow, so hold on it long enough to be clearly visible in the recording.
|
||||
await page.getByText('Bank account connected').waitFor({ timeout: 15000 });
|
||||
await page.getByText('go back to the app').waitFor({ timeout: 5000 });
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const { connection } = artisanJson([
|
||||
'e2e:banking-fixture',
|
||||
'inspect',
|
||||
settings.email,
|
||||
]);
|
||||
assert(connection, 'session-lost connection exists');
|
||||
assert(
|
||||
connection.session_id === true,
|
||||
'connection was finalized despite the lost session',
|
||||
);
|
||||
log(
|
||||
`✓ confirmation screen shown · connection finalized (status ${connection.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Force English so the Enable Banking / bank sandbox pages render with the labels
|
||||
// the selectors expect (they otherwise follow the browser locale).
|
||||
const CONTEXT_OPTIONS = {
|
||||
ignoreHTTPSErrors: true,
|
||||
locale: 'en-US',
|
||||
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
||||
};
|
||||
|
||||
// Injected into every page so the recordings show where the cursor is and when it
|
||||
// clicks (Playwright drives a real but invisible mouse). Runs on each document,
|
||||
// including the external bank pages.
|
||||
function installMouseHelper() {
|
||||
if (window.__mouseHelperInstalled) {
|
||||
return;
|
||||
}
|
||||
window.__mouseHelperInstalled = true;
|
||||
const attach = () => {
|
||||
const dot = document.createElement('div');
|
||||
dot.style.cssText =
|
||||
'pointer-events:none;position:fixed;top:0;left:0;z-index:2147483647;' +
|
||||
'width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;' +
|
||||
'background:rgba(220,38,38,.35);border:2px solid rgba(220,38,38,.9);' +
|
||||
'transition:transform .08s ease,background .15s ease;';
|
||||
document.body.appendChild(dot);
|
||||
document.addEventListener(
|
||||
'mousemove',
|
||||
(e) => {
|
||||
dot.style.left = e.clientX + 'px';
|
||||
dot.style.top = e.clientY + 'px';
|
||||
},
|
||||
true,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mousedown',
|
||||
() => {
|
||||
dot.style.transform = 'scale(.6)';
|
||||
dot.style.background = 'rgba(220,38,38,.7)';
|
||||
},
|
||||
true,
|
||||
);
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
dot.style.transform = 'scale(1)';
|
||||
dot.style.background = 'rgba(220,38,38,.35)';
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
if (document.body) {
|
||||
attach();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', attach);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the webm Playwright produces to mp4 (plays in QuickTime / Preview / most
|
||||
// players). Falls back to the webm if ffmpeg isn't installed.
|
||||
function toMp4(webmPath) {
|
||||
const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
|
||||
try {
|
||||
execFileSync(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-i',
|
||||
webmPath,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
mp4Path,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
rmSync(webmPath, { force: true });
|
||||
return mp4Path;
|
||||
} catch {
|
||||
return webmPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
log(`Seeding fixtures (base url: ${BASE_URL})`);
|
||||
const fixtures = artisanJson(['e2e:banking-fixture', 'seed']);
|
||||
|
||||
rmSync(VIDEO_DIR, { recursive: true, force: true });
|
||||
mkdirSync(VIDEO_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: HEADLESS });
|
||||
|
||||
const results = [];
|
||||
// Each scenario gets its own recording context, so it produces one video and
|
||||
// starts from a clean session.
|
||||
const run = async (name, fn) => {
|
||||
const context = await browser.newContext({
|
||||
...CONTEXT_OPTIONS,
|
||||
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 800 } },
|
||||
});
|
||||
await context.addInitScript(installMouseHelper);
|
||||
const page = await context.newPage();
|
||||
const video = page.video();
|
||||
let ok = true;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
await fn(page);
|
||||
// A guaranteed clear still of the final screen, independent of video timing.
|
||||
await page
|
||||
.screenshot({ path: `${VIDEO_DIR}/${name}.png` })
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
error = e.message;
|
||||
await page
|
||||
.screenshot({
|
||||
path: `${VIDEO_DIR}/failure-${name}.png`,
|
||||
fullPage: true,
|
||||
})
|
||||
.catch(() => {});
|
||||
log(`✗ ${e.message.split('\n')[0]} (at ${page.url()})`);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
const webmPath = `${VIDEO_DIR}/${name}.webm`;
|
||||
await video?.saveAs(webmPath).catch(() => {});
|
||||
await video?.delete().catch(() => {});
|
||||
results.push({ name, ok, error, video: toMp4(webmPath) });
|
||||
};
|
||||
|
||||
try {
|
||||
await run('settings-connect', (page) =>
|
||||
scenarioSettingsConnect(page, fixtures.settings),
|
||||
);
|
||||
await run('onboarding-connect', (page) =>
|
||||
scenarioOnboardingConnect(page, fixtures.onboarding),
|
||||
);
|
||||
await run('reconnect-expired', (page) =>
|
||||
scenarioReconnect(page, fixtures.settings),
|
||||
);
|
||||
await run('session-lost-return', (page) =>
|
||||
scenarioSessionLost(page, fixtures.settings),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
process.stdout.write('\nLive Enable Banking e2e results:\n');
|
||||
for (const result of results) {
|
||||
process.stdout.write(
|
||||
` ${result.ok ? 'PASS' : 'FAIL'} ${result.name} → ${result.video}${result.ok ? '' : ' — ' + result.error}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(results.every((r) => r.ok) ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`\nFatal: ${error.stack ?? error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Services\Banking\EnableBankingProvider;
|
||||
|
||||
/**
|
||||
* In-memory stand-in for {@see EnableBankingProvider} used by
|
||||
* browser tests. It mirrors the Enable Banking response shapes so the full UI →
|
||||
* authorize → callback → mapping → sync flow can be exercised in CI without hitting
|
||||
* the live sandbox.
|
||||
*
|
||||
* The connect flow spans several HTTP requests handled by the same in-process kernel,
|
||||
* so this instance is registered as a container singleton and keeps its state (the
|
||||
* pending authorization plus stable IBANs) between calls. New session/account UIDs are
|
||||
* minted on every {@see createSession()} call, exactly like Enable Banking does, so the
|
||||
* reconnect path that re-matches accounts by IBAN is covered too.
|
||||
*/
|
||||
final class FakeBankingProvider implements BankingProviderInterface
|
||||
{
|
||||
/**
|
||||
* The authorization started by the most recent startAuthorization() call.
|
||||
*
|
||||
* @var array{aspsp_name: string, country: string, state: string}|null
|
||||
*/
|
||||
private ?array $pendingAuthorization = null;
|
||||
|
||||
private int $sessionCounter = 0;
|
||||
|
||||
/**
|
||||
* Stable IBANs per institution, so reconnects re-match existing accounts by IBAN.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private array $ibansByAspsp = [
|
||||
'Banco de Sabadell' => ['ES1800810602610001111120', 'ES6200810602620003333338'],
|
||||
'BBVA' => ['ES2100750000000000000001', 'ES2100750000000000000002'],
|
||||
];
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getInstitutions(string $countryCode): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Banco de Sabadell', 'country' => $countryCode, 'logo' => null, 'maximum_consent_validity' => 7776000],
|
||||
['name' => 'BBVA', 'country' => $countryCode, 'logo' => null, 'maximum_consent_validity' => 7776000],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function startAuthorization(string $aspspName, string $countryCode, string $redirectUrl, string $state): array
|
||||
{
|
||||
$this->pendingAuthorization = [
|
||||
'aspsp_name' => $aspspName,
|
||||
'country' => $countryCode,
|
||||
'state' => $state,
|
||||
];
|
||||
|
||||
// Skip the real bank redirect: send the browser straight back to our own
|
||||
// callback on the current host (the test server) with a synthetic code+state.
|
||||
// route() resolves to the running browser-test server, so the flow stays
|
||||
// in-process instead of navigating to the configured production redirect host.
|
||||
return [
|
||||
'url' => route('open-banking.callback', ['code' => 'fake-code-'.$state, 'state' => $state]),
|
||||
'authorization_id' => 'fake-auth-'.$state,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function createSession(string $code): array
|
||||
{
|
||||
$this->sessionCounter++;
|
||||
|
||||
$aspspName = $this->pendingAuthorization['aspsp_name'] ?? 'Banco de Sabadell';
|
||||
$country = $this->pendingAuthorization['country'] ?? 'ES';
|
||||
$ibans = $this->ibansByAspsp[$aspspName] ?? ['ES0000000000000000000001'];
|
||||
|
||||
$accounts = [];
|
||||
foreach ($ibans as $index => $iban) {
|
||||
$accounts[] = [
|
||||
'uid' => sprintf('fake-uid-%d-%d', $this->sessionCounter, $index),
|
||||
'currency' => 'EUR',
|
||||
'name' => sprintf('%s account %d', $aspspName, $index + 1),
|
||||
'account_id' => ['iban' => $iban],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'session_id' => 'fake-session-'.$this->sessionCounter,
|
||||
'aspsp' => ['name' => $aspspName, 'country' => $country],
|
||||
'accounts' => $accounts,
|
||||
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getTransactions(string $accountId, string $dateFrom, string $dateTo, ?string $continuationKey = null, ?string $strategy = null): array
|
||||
{
|
||||
return ['transactions' => [], 'continuation_key' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getBalances(string $accountId): array
|
||||
{
|
||||
return ['balances' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getSession(string $sessionId): array
|
||||
{
|
||||
return [
|
||||
'status' => 'AUTHORIZED',
|
||||
'access' => ['valid_until' => now()->addDays(90)->toIso8601String()],
|
||||
'accounts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getAccount(string $accountId): array
|
||||
{
|
||||
return [
|
||||
'uid' => $accountId,
|
||||
'account_id' => ['iban' => 'ES0000000000000000000000'],
|
||||
'currency' => 'EUR',
|
||||
'name' => 'Fake account',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function revokeSession(string $sessionId): void
|
||||
{
|
||||
// No-op: nothing to revoke for the in-memory fake.
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue