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

535 lines
19 KiB
PHP

<?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();
try {
$job->handle($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);
$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();
$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();
});