fix(banking): retry failed sync connections and log every sync attempt (#251)
## Summary - **Fixes the broken retry mechanism** — after the first failed attempt set status to `Error`, subsequent retry attempts bailed out because `isActive()` returns `false` for `Error`. Now both `Active` and `Error` statuses are syncable. - **Adds auto-retry across scheduled runs** — `SyncAllBankingConnectionsJob` and `banking:sync` command now include `Error` connections where `consecutive_sync_failures < 3` (configurable via `MAX_SCHEDULED_RETRIES`). After 3 full dispatch cycles, manual intervention is required. - **Logs every sync attempt to DB** — new `banking_sync_logs` table records status (Success/Failed/Skipped), attempt number, error details, duration, and metadata for each sync. ## Changes ### Core logic (`SyncBankingConnectionJob`) - `isSyncableStatus()` allows both `Active` and `Error` through the gate - Temporary errors: status only set to `Error` on the final attempt (attempt 3); earlier attempts re-throw without changing status - Permanent auth errors (401/403): `$this->fail()` called immediately, `consecutive_sync_failures` set beyond the cap - Rate limit (429): handled silently (existing behavior preserved) - Every attempt is logged to `banking_sync_logs` ### Query updates - `SyncAllBankingConnectionsJob`: includes `Error` connections under retry cap - `SyncBankingConnections` command: same query update for `--user`/`--connection` filtered runs ### Controller updates - `ConnectionController::sync()` and `updateCredentials()` reset `consecutive_sync_failures` to 0 ### New files - `BankingSyncLogStatus` enum (Success, Failed, Skipped) - `BankingSyncLog` model - Two migrations: `add_consecutive_sync_failures` column, `create_banking_sync_logs` table ### Tests - Updated 3 existing auth error tests in `SyncBankingConnectionJobTest` (24 pass) - Added 17 new tests in `SyncRetryAndLoggingTest` covering retry behavior, sync logging, scheduled retry inclusion/exclusion, and manual retry reset - All 10 `SyncBankingConnectionsCommandTest` tests still pass
This commit is contained in:
parent
755452d6ce
commit
f3b5929ecc
|
|
@ -41,7 +41,13 @@ class SyncBankingConnections extends Command
|
|||
}
|
||||
|
||||
$query = BankingConnection::query()
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BankingSyncLogStatus: string
|
||||
{
|
||||
case Success = 'success';
|
||||
case Failed = 'failed';
|
||||
case Skipped = 'skipped';
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ class ConnectionController extends Controller
|
|||
$connection->update([
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'error_message' => null,
|
||||
'consecutive_sync_failures' => 0,
|
||||
]);
|
||||
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
|
@ -88,6 +89,7 @@ class ConnectionController extends Controller
|
|||
...$updateData,
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'error_message' => null,
|
||||
'consecutive_sync_failures' => 0,
|
||||
]);
|
||||
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ class SyncAllBankingConnectionsJob implements ShouldQueue
|
|||
public function handle(): void
|
||||
{
|
||||
BankingConnection::query()
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Error)
|
||||
->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES);
|
||||
});
|
||||
})
|
||||
->where(function ($query) {
|
||||
$query->whereNull('valid_until')
|
||||
->orWhere('valid_until', '>', now());
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingSyncLogStatus;
|
||||
use App\Mail\BankingConnectionAuthFailedEmail;
|
||||
use App\Mail\BankTransactionsSyncedEmail;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\BankingSyncLog;
|
||||
use App\Services\Banking\BalanceSyncService;
|
||||
use App\Services\Banking\BinanceBalanceSyncService;
|
||||
use App\Services\Banking\BinanceClient;
|
||||
|
|
@ -32,6 +34,12 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
|
||||
public int $backoff = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of scheduled sync cycles that will auto-retry
|
||||
* a connection in Error state before requiring manual intervention.
|
||||
*/
|
||||
public const int MAX_SCHEDULED_RETRIES = 3;
|
||||
|
||||
public function __construct(
|
||||
public BankingConnection $bankingConnection,
|
||||
public bool $fullSync = false,
|
||||
|
|
@ -45,20 +53,26 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
public function handle(TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void
|
||||
{
|
||||
$connection = $this->bankingConnection;
|
||||
$startTime = microtime(true);
|
||||
|
||||
if ($connection->isEnableBanking() && $connection->isExpired()) {
|
||||
$connection->update(['status' => BankingConnectionStatus::Expired]);
|
||||
Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]);
|
||||
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $connection->isActive()) {
|
||||
if (! $this->isSyncableStatus($connection)) {
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'not_syncable', 'status' => $connection->status->value]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$isFirstSync = ! $connection->last_synced_at || $this->fullSync;
|
||||
$metadata = [];
|
||||
|
||||
if ($connection->isIndexaCapital()) {
|
||||
$this->syncIndexaCapital($connection, $isFirstSync);
|
||||
|
|
@ -67,37 +81,112 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
} elseif ($connection->isBitpanda()) {
|
||||
$this->syncBitpanda($connection);
|
||||
} else {
|
||||
$this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync);
|
||||
$metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync);
|
||||
}
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'last_synced_at' => now(),
|
||||
'error_message' => null,
|
||||
'consecutive_sync_failures' => 0,
|
||||
]);
|
||||
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Success, $startTime, metadata: $metadata ?: null);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Banking sync failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'error' => $e->getMessage(),
|
||||
'attempt' => $this->attempts(),
|
||||
]);
|
||||
|
||||
if ($this->isRateLimitError($e)) {
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Failed, $startTime, $e);
|
||||
|
||||
if ($this->isAuthError($e)) {
|
||||
$this->handlePermanentError($connection, $e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleTemporaryError($connection, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permanent errors (auth failures) that should not be retried.
|
||||
*/
|
||||
private function handlePermanentError(BankingConnection $connection, \Throwable $e): void
|
||||
{
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::Error,
|
||||
'error_message' => $this->friendlyErrorMessage($e),
|
||||
'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1,
|
||||
]);
|
||||
|
||||
if ($this->isApiKeyProvider($connection)) {
|
||||
Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
|
||||
$connection->user,
|
||||
$connection,
|
||||
));
|
||||
}
|
||||
|
||||
$this->fail($e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle temporary errors that may resolve on retry.
|
||||
*/
|
||||
private function handleTemporaryError(BankingConnection $connection, \Throwable $e): void
|
||||
{
|
||||
$isFinalAttempt = $this->attempts() >= $this->tries;
|
||||
|
||||
if ($isFinalAttempt) {
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::Error,
|
||||
'error_message' => $this->friendlyErrorMessage($e),
|
||||
'consecutive_sync_failures' => $connection->consecutive_sync_failures + 1,
|
||||
]);
|
||||
|
||||
if ($this->isAuthError($e) && $this->isApiKeyProvider($connection) && $this->attempts() >= $this->tries) {
|
||||
Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
|
||||
$connection->user,
|
||||
$connection,
|
||||
));
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connection status allows syncing.
|
||||
* Allows both Active and Error (for auto-retry from scheduled runs).
|
||||
*/
|
||||
private function isSyncableStatus(BankingConnection $connection): bool
|
||||
{
|
||||
return in_array($connection->status, [
|
||||
BankingConnectionStatus::Active,
|
||||
BankingConnectionStatus::Error,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logSyncAttempt(
|
||||
BankingConnection $connection,
|
||||
BankingSyncLogStatus $status,
|
||||
float $startTime,
|
||||
?\Throwable $error = null,
|
||||
?array $metadata = null,
|
||||
): void {
|
||||
$durationMs = (int) round((microtime(true) - $startTime) * 1000);
|
||||
|
||||
BankingSyncLog::create([
|
||||
'banking_connection_id' => $connection->id,
|
||||
'status' => $status,
|
||||
'attempt' => $this->attempts(),
|
||||
'error_message' => $error?->getMessage(),
|
||||
'error_class' => $error ? get_class($error) : null,
|
||||
'duration_ms' => $durationMs,
|
||||
'metadata' => $metadata,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function syncIndexaCapital(BankingConnection $connection, bool $isFirstSync): void
|
||||
|
|
@ -141,7 +230,10 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): void
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync, bool $isFirstSync): array
|
||||
{
|
||||
$dateFrom = $isFirstSync
|
||||
? now()->subYear()->toDateString()
|
||||
|
|
@ -189,6 +281,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
$transactionsPerBank,
|
||||
));
|
||||
}
|
||||
|
||||
return ['transactions_synced' => $totalTransactions, 'transactions_per_bank' => $transactionsPerBank];
|
||||
}
|
||||
|
||||
private function friendlyErrorMessage(\Throwable $e): string
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
|
|
@ -17,6 +18,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property BankingConnectionStatus $status
|
||||
* @property Carbon|null $valid_until
|
||||
* @property Carbon|null $last_synced_at
|
||||
* @property int $consecutive_sync_failures
|
||||
* @property array<int, mixed>|null $pending_accounts_data
|
||||
*/
|
||||
class BankingConnection extends Model
|
||||
|
|
@ -36,6 +38,7 @@ class BankingConnection extends Model
|
|||
'valid_until',
|
||||
'last_synced_at',
|
||||
'error_message',
|
||||
'consecutive_sync_failures',
|
||||
'pending_accounts_data',
|
||||
'api_token',
|
||||
'api_secret',
|
||||
|
|
@ -73,6 +76,18 @@ class BankingConnection extends Model
|
|||
return $this->hasMany(Account::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<BankingSyncLog, $this> */
|
||||
public function syncLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(BankingSyncLog::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<BankingSyncLog, $this> */
|
||||
public function latestSyncLog(): HasOne
|
||||
{
|
||||
return $this->hasOne(BankingSyncLog::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === BankingConnectionStatus::Active;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BankingSyncLogStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BankingSyncLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'banking_connection_id',
|
||||
'status',
|
||||
'attempt',
|
||||
'error_message',
|
||||
'error_class',
|
||||
'duration_ms',
|
||||
'metadata',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => BankingSyncLogStatus::class,
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<BankingConnection, $this> */
|
||||
public function bankingConnection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BankingConnection::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -122,6 +122,7 @@ class BankingConnectionFactory extends Factory
|
|||
return $this->state(fn (array $attributes) => [
|
||||
'status' => BankingConnectionStatus::Error,
|
||||
'error_message' => 'Connection failed: bank returned an error',
|
||||
'consecutive_sync_failures' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('banking_connections', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('consecutive_sync_failures')->default(0)->after('error_message');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('banking_connections', function (Blueprint $table) {
|
||||
$table->dropColumn('consecutive_sync_failures');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banking_sync_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignUuid('banking_connection_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('status');
|
||||
$table->unsignedTinyInteger('attempt')->default(1);
|
||||
$table->text('error_message')->nullable();
|
||||
$table->string('error_class')->nullable();
|
||||
$table->unsignedInteger('duration_ms')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banking_sync_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -626,7 +626,7 @@ test('bitpanda sync does not send email', function () {
|
|||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('sends auth failed email on final retry for indexa capital 401 error', function () {
|
||||
test('sends auth failed email immediately for indexa capital 401 error', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
|
@ -647,38 +647,26 @@ test('sends auth failed email on final retry for indexa capital 401 error', func
|
|||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate final attempt (3 of 3) - SHOULD send email
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$mockQueueJob = Mockery::mock(Job::class);
|
||||
$mockQueueJob->shouldReceive('attempts')->andReturn(3);
|
||||
$mockQueueJob->shouldReceive('attempts')->andReturn(1);
|
||||
$mockQueueJob->shouldReceive('isReleased')->andReturn(false);
|
||||
$mockQueueJob->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$mockQueueJob->shouldReceive('hasFailed')->andReturn(false);
|
||||
$mockQueueJob->shouldReceive('fail')->once();
|
||||
$job->job = $mockQueueJob;
|
||||
|
||||
expect($connection->isActive())->toBeTrue();
|
||||
expect($job->attempts())->toBe(3);
|
||||
|
||||
$threw = false;
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (Throwable $e) {
|
||||
$threw = true;
|
||||
expect($e)->toBeInstanceOf(RequestException::class);
|
||||
expect($e->response->status())->toBe(401);
|
||||
}
|
||||
|
||||
expect($threw)->toBeTrue();
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Error);
|
||||
expect($connection->error_message)->toContain('Authentication failed');
|
||||
expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1);
|
||||
|
||||
Mail::assertQueued(BankingConnectionAuthFailedEmail::class);
|
||||
});
|
||||
|
||||
test('does not send auth failed email before final retry attempt', function () {
|
||||
test('auth error sends email and fails job on any attempt', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
|
@ -699,21 +687,22 @@ test('does not send auth failed email before final retry attempt', function () {
|
|||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate attempt 1 of 3 - should NOT send email
|
||||
// Simulate attempt 1 of 3 - auth errors should STILL send email immediately
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(1);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
$job->job->shouldReceive('fail')->once();
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (Throwable) {
|
||||
// Expected
|
||||
}
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class);
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Error);
|
||||
expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1);
|
||||
|
||||
Mail::assertQueued(BankingConnectionAuthFailedEmail::class);
|
||||
});
|
||||
|
||||
test('does not send auth failed email for non-auth errors', function () {
|
||||
|
|
@ -791,7 +780,7 @@ test('does not send auth failed email for enablebanking connections', function (
|
|||
Mail::assertNotQueued(BankingConnectionAuthFailedEmail::class);
|
||||
});
|
||||
|
||||
test('sends auth failed email for binance 403 error on final attempt', function () {
|
||||
test('sends auth failed email immediately for binance 403 error', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
|
||||
|
|
@ -815,16 +804,13 @@ test('sends auth failed email for binance 403 error on final attempt', function
|
|||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(3);
|
||||
$job->job->shouldReceive('attempts')->andReturn(1);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
$job->job->shouldReceive('fail')->once();
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (Throwable) {
|
||||
// Expected
|
||||
}
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
Mail::assertQueued(BankingConnectionAuthFailedEmail::class, function ($mail) use ($user, $connection) {
|
||||
return $mail->hasTo($user->email)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,531 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingSyncLogStatus;
|
||||
use App\Jobs\SyncAllBankingConnectionsJob;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Account;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\BankingSyncLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Banking\BalanceSyncService;
|
||||
use App\Services\Banking\TransactionSyncService;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// --- Retry Behavior Tests ---
|
||||
|
||||
test('temporary error on non-final attempt does not set error status', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(500))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate attempt 1 of 3
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(1);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
|
||||
$threw = false;
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (RequestException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
expect($threw)->toBeTrue();
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||
expect($connection->error_message)->toBeNull();
|
||||
expect($connection->consecutive_sync_failures)->toBe(0);
|
||||
});
|
||||
|
||||
test('temporary error on final attempt sets error status and increments consecutive failures', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(500))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate final attempt (3 of 3)
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(3);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (RequestException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Error);
|
||||
expect($connection->error_message)->toContain('provider is experiencing issues');
|
||||
expect($connection->consecutive_sync_failures)->toBe(1);
|
||||
});
|
||||
|
||||
test('consecutive sync failures accumulate across dispatch cycles', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
'consecutive_sync_failures' => 2,
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(500))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate final attempt of a new dispatch cycle
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(3);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (RequestException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->consecutive_sync_failures)->toBe(3);
|
||||
});
|
||||
|
||||
test('successful sync resets consecutive failures', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
'consecutive_sync_failures' => 2,
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->once()->andReturn(0);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
$balanceSync->shouldReceive('sync')->once();
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||
expect($connection->error_message)->toBeNull();
|
||||
expect($connection->consecutive_sync_failures)->toBe(0);
|
||||
});
|
||||
|
||||
test('connection in error status can be synced', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->once()->andReturn(3);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
$balanceSync->shouldReceive('sync')->once();
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||
expect($connection->last_synced_at)->not->toBeNull();
|
||||
expect($connection->error_message)->toBeNull();
|
||||
});
|
||||
|
||||
test('auth error sets consecutive failures beyond the cap', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->indexaCapital()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'IC-001',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.indexacapital.com/*' => Http::response(['error' => 'Unauthorized'], 401),
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(1);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
$job->job->shouldReceive('fail')->once();
|
||||
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->consecutive_sync_failures)->toBe(SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1);
|
||||
});
|
||||
|
||||
// --- Sync Log Tests ---
|
||||
|
||||
test('successful sync creates a success log entry', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->once()->andReturn(5);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
$balanceSync->shouldReceive('sync')->once();
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Success);
|
||||
expect($log->error_message)->toBeNull();
|
||||
expect($log->error_class)->toBeNull();
|
||||
expect($log->duration_ms)->toBeGreaterThanOrEqual(0);
|
||||
expect($log->metadata['transactions_synced'])->toBe(5);
|
||||
expect($log->metadata)->toHaveKey('transactions_per_bank');
|
||||
});
|
||||
|
||||
test('failed sync creates a failed log entry', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(500))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(1);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (RequestException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Failed);
|
||||
expect($log->attempt)->toBe(1);
|
||||
expect($log->error_message)->not->toBeNull();
|
||||
expect($log->error_class)->toBe(RequestException::class);
|
||||
});
|
||||
|
||||
test('skipped sync creates a skipped log entry for expired connection', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'valid_until' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Skipped);
|
||||
expect($log->metadata)->toBe(['reason' => 'expired']);
|
||||
});
|
||||
|
||||
test('skipped sync creates a skipped log entry for non-syncable status', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->pending()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Skipped);
|
||||
expect($log->metadata)->toMatchArray(['reason' => 'not_syncable']);
|
||||
});
|
||||
|
||||
test('rate limit error creates a failed log entry', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(429))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe(BankingSyncLogStatus::Failed);
|
||||
});
|
||||
|
||||
test('sync log records attempt number', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->andThrow(
|
||||
new RequestException(
|
||||
new Illuminate\Http\Client\Response(new Response(500))
|
||||
)
|
||||
);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
|
||||
// Simulate attempt 2 of 3
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->job = Mockery::mock(Job::class);
|
||||
$job->job->shouldReceive('attempts')->andReturn(2);
|
||||
$job->job->shouldReceive('isReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('isDeletedOrReleased')->andReturn(false);
|
||||
$job->job->shouldReceive('hasFailed')->andReturn(false);
|
||||
|
||||
try {
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
} catch (RequestException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->first();
|
||||
expect($log->attempt)->toBe(2);
|
||||
});
|
||||
|
||||
test('connection sync logs relationship works', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$connection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'last_synced_at' => now()->subDay(),
|
||||
]);
|
||||
Account::factory()->connected()->create([
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$transactionSync = Mockery::mock(TransactionSyncService::class);
|
||||
$transactionSync->shouldReceive('sync')->once()->andReturn(0);
|
||||
|
||||
$balanceSync = Mockery::mock(BalanceSyncService::class);
|
||||
$balanceSync->shouldReceive('sync')->once();
|
||||
|
||||
$job = new SyncBankingConnectionJob($connection);
|
||||
$job->handle($transactionSync, $balanceSync);
|
||||
|
||||
expect($connection->syncLogs()->count())->toBe(1);
|
||||
expect($connection->latestSyncLog)->not->toBeNull();
|
||||
expect($connection->latestSyncLog->status)->toBe(BankingSyncLogStatus::Success);
|
||||
});
|
||||
|
||||
// --- SyncAllBankingConnectionsJob Retry Tests ---
|
||||
|
||||
test('scheduled sync includes error connections under retry cap', function () {
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$activeConnection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$errorConnection = BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'consecutive_sync_failures' => 1,
|
||||
]);
|
||||
|
||||
$job = new SyncAllBankingConnectionsJob;
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, 2);
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $activeConnection->id);
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $errorConnection->id);
|
||||
});
|
||||
|
||||
test('scheduled sync excludes error connections at or over retry cap', function () {
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$activeConnection = BankingConnection::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// At the cap - should be excluded
|
||||
BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'consecutive_sync_failures' => SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES,
|
||||
]);
|
||||
|
||||
// Over the cap (auth error) - should be excluded
|
||||
BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'consecutive_sync_failures' => SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES + 1,
|
||||
]);
|
||||
|
||||
$job = new SyncAllBankingConnectionsJob;
|
||||
$job->handle();
|
||||
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, 1);
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $activeConnection->id);
|
||||
});
|
||||
|
||||
test('scheduled sync excludes error connections with expired valid_until', function () {
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'consecutive_sync_failures' => 1,
|
||||
'valid_until' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$job = new SyncAllBankingConnectionsJob;
|
||||
$job->handle();
|
||||
|
||||
Queue::assertNotPushed(SyncBankingConnectionJob::class);
|
||||
});
|
||||
|
||||
// --- Manual Retry Reset Tests ---
|
||||
|
||||
test('manual sync resets consecutive sync failures', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
Feature::for($user)->activate('open-banking');
|
||||
|
||||
$connection = BankingConnection::factory()->error()->create([
|
||||
'user_id' => $user->id,
|
||||
'consecutive_sync_failures' => 2,
|
||||
]);
|
||||
|
||||
Queue::fake(SyncBankingConnectionJob::class);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('settings.connections.sync', $connection))
|
||||
->assertRedirect();
|
||||
|
||||
$connection->refresh();
|
||||
expect($connection->consecutive_sync_failures)->toBe(0);
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||
expect($connection->error_message)->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue