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 { runSync($job, $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 { runSync($job, $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('temporary error on non-final attempt is not logged', 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); Log::spy(); // Attempt 1 of 3: the retry will recover, so nothing should be reported yet. $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 { runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected: rethrown so the queue retries it. } Log::shouldNotHaveReceived('log'); }); test('temporary error on final attempt is logged', 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); Log::spy(); // Final attempt (3 of 3): the connection gives up, so it must be reported. $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 { runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected } Log::shouldHaveReceived('log')->with('error', 'Banking sync failed', Mockery::any())->once(); }); test('transient banking provider error on final attempt uses retry later message', 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 TransientBankingProviderException( 'EnableBanking bank connector failed while fetching account transactions.', provider: 'enablebanking', statusCode: 400, providerCode: 'ASPSP_ERROR', ) ); $balanceSync = Mockery::mock(BalanceSyncService::class); $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); $threw = false; try { runSync($job, $transactionSync, $balanceSync); } catch (TransientBankingProviderException) { $threw = true; } expect($threw)->toBeTrue(); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Error); expect($connection->error_message)->toContain('bank provider is temporarily unavailable'); expect($connection->consecutive_sync_failures)->toBe(1); }); test('expired session marks the connection expired and emails the user instead of reporting an error', function () { Mail::fake(); $user = User::factory()->onboarded()->create(); $connection = BankingConnection::factory()->create([ 'user_id' => $user->id, 'last_synced_at' => now()->subDay(), 'valid_until' => now()->addMonths(3), ]); 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 ExpiredBankingSessionException( 'EnableBanking session expired while fetching account transactions.', ) ); $balanceSync = Mockery::mock(BalanceSyncService::class); $job = new SyncBankingConnectionJob($connection); // An expired session is an expected lifecycle event: it must not throw or be reported. runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Expired); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log->status)->toBe(BankingSyncLogStatus::Skipped); expect($log->metadata)->toBe(['reason' => 'expired']); Mail::assertQueued(BankingConnectionExpiredEmail::class, function (BankingConnectionExpiredEmail $mail) use ($user, $connection) { return $mail->user->is($user) && $mail->bankingConnection->is($connection); }); }); test('an inaccessible account is skipped and the rest of the connection still syncs', 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' => 'good-account', ]); Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'dead-account', ]); $transactionSync = Mockery::mock(TransactionSyncService::class); $transactionSync->shouldReceive('sync')->andReturnUsing(function ($account) { if ($account->external_account_id === 'dead-account') { throw new InaccessibleBankAccountException('EnableBanking account is no longer accessible.'); } return 2; }); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); // One dead account must not break the whole connection sync or be reported. runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); expect($connection->error_message)->toBeNull(); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log->status)->toBe(BankingSyncLogStatus::Success); expect($log->metadata['transactions_synced'])->toBe(2); }); test('an account whose period the bank refuses is skipped, not crashed, and the rest still syncs', 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' => 'good-account', ]); Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'narrow-refused-account', ]); $transactionSync = Mockery::mock(TransactionSyncService::class); $transactionSync->shouldReceive('sync')->andReturnUsing(function ($account) { if ($account->external_account_id === 'narrow-refused-account') { throw new WrongTransactionsPeriodException('EnableBanking refused every window.'); } return 2; }); $balanceSync = Mockery::mock(BalanceSyncService::class); $balanceSync->shouldReceive('sync')->once(); $job = new SyncBankingConnectionJob($connection); // The refused account must not break the connection sync, mark it Error, or // be reported — same resilience as an inaccessible account. runSync($job, $transactionSync, $balanceSync); $connection->refresh(); expect($connection->status)->toBe(BankingConnectionStatus::Active); expect($connection->error_message)->toBeNull(); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($log->status)->toBe(BankingSyncLogStatus::Success); expect($log->metadata['transactions_synced'])->toBe(2); }); 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 { runSync($job, $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); runSync($job, $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); runSync($job, $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(); try { runSync($job, $transactionSync, $balanceSync); } catch (RequestException) { // Expected for auth failures after manually failing the job. } $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); runSync($job, $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 { runSync($job, $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 and emails user for newly expired connection', function () { Mail::fake(); $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); runSync($job, $transactionSync, $balanceSync); $connection->refresh(); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); expect($connection->status)->toBe(BankingConnectionStatus::Expired); expect($log)->not->toBeNull(); expect($log->status)->toBe(BankingSyncLogStatus::Skipped); expect($log->metadata)->toBe(['reason' => 'expired']); Mail::assertQueued(BankingConnectionExpiredEmail::class, function (BankingConnectionExpiredEmail $mail) use ($user, $connection) { return $mail->user->is($user) && $mail->bankingConnection->is($connection); }); }); 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); runSync($job, $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); runSync($job, $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 { runSync($job, $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); runSync($job, $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); }); test('scheduled sync includes active enablebanking connections whose valid_until expired', function () { Queue::fake(SyncBankingConnectionJob::class); $user = User::factory()->onboarded()->create(); $connection = BankingConnection::factory()->create([ 'user_id' => $user->id, 'status' => BankingConnectionStatus::Active, 'valid_until' => now()->subDay(), ]); $job = new SyncAllBankingConnectionsJob; $job->handle(); Queue::assertPushed(SyncBankingConnectionJob::class, 1); Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $connection->id); }); test('scheduled sync skips connections still within rate limit backoff window', function () { Queue::fake(SyncBankingConnectionJob::class); $user = User::factory()->onboarded()->create(); BankingConnection::factory()->create([ 'user_id' => $user->id, 'rate_limited_until' => now()->addHour(), ]); $eligible = BankingConnection::factory()->create([ 'user_id' => $user->id, 'rate_limited_until' => now()->subMinute(), ]); $job = new SyncAllBankingConnectionsJob; $job->handle(); Queue::assertPushed(SyncBankingConnectionJob::class, 1); Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $eligible->id); }); // --- Manual Retry Reset Tests --- test('manual sync resets consecutive sync failures', function () { $user = User::factory()->onboarded()->create(); $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(); });