I have a method to process a loan request, it creates a new loan record from form inputs and uses it as a dependency for an API call.
I wrote a test for this method that includes faking an API call like this:
Http::fake(
[
config('services.dummy.url').'/v3/transfers' => Http::response(
[
'status' => 'success',
'message' => 'test message',
'data' => [
'tx_ref' => $ref,
'meta' => [
'user_id' => $user->id,
'loan_id' => $loan->id, //not available in test
'loanProvider_id' => $loanProvider->id,
'transfer_description' => 'Loan Disbursement',
],
],
],
200
),
config('services.dummy.url').'/v3/transactions/'.$ref.'/verify' => Http::response(
[
'status' => 'success',
'message' => 'test message',
'meta' => [
'loan_id' => $loan->id, //not available in test
],
],
200
),
]
);
The thing is that I need the loan record for the API call but it's not created within my test but within the method.
public function processLoanApplication(Request $request, User $user) {
$loan = $user->loans()->create([...]);
// API call
$transferService->disburseLoan($loan);
}
This is the disburseLoan method:
public function disburseLoan(Loan $loan)
{
$data = $this->debitLoanProvider($loan);
$result = $this->verifyTransfer($data['data']['tx_ref']);
$this->useRecurringTransferVerificationResult($result);
}
It calls the 2 APIs faked in the test and result is used for some housekeeping.
Depending on the result of the API calls, this $this->useRecurringTransferVerificationResult($result);
calls other methods to create a transfer record, update the loan record, etc.
What's the correct way to set this test up?
I ended up with this setup (with help from ChatGPT). This is the complete test.
test('that it processes a personal loan request and disbursement was successful', function () {
// Arrange
Storage::fake('public');
$loanProvider = LoanProvider::factory()->create([
'loan_type' => 'Personal Loan',
'api_url' => 'dummy.providerurl',
'api_secret' => 'dummyprovidersecret',
'endpoints' => ['loanApplication' => '/loan/application']
]);
$bank = Bank::factory()->create();
$security = Security::factory()->create([
'asset_category' => 'Equities',
'market_price' => 100.0,
]);
$user = User::factory()->verified()->identityVerified()->create();
$user->billing->update([
'bank_code' => $bank->bank_code,
'account_number' => '1234567890'
]);
$targetAmount = 15000000;
$goalHorizon = today()->addYears(10);
$horizonInYears = (int) (today()->diffInYears($goalHorizon));
$recommendedSavings = $targetAmount / $horizonInYears;
$totalProjectedSavings = $recommendedSavings * $horizonInYears;
$totalProjectedReturns = $totalProjectedSavings * 15;
$goal = $user->financialGoals()->create([
'goal_name' => 'fake goal',
'user_first_name' => $this->authUser->first_name,
'user_last_name' => $this->authUser->last_name,
'target_amount' => $targetAmount,
'goal_horizon' => $goalHorizon,
'savings_type' => 'Periodic Savings',
'recommended_savings' => $recommendedSavings,
'total_projected_savings' => $totalProjectedSavings,
'total_projected_returns' => $totalProjectedReturns,
'renewal_date' => today(),
'cumulative_cash_savings' => 750000,
'set_for_implementation' => true,
'fee_discount' => null,
'verified_organisation_documents' => false,
'implementation_status' => 'Active',
'has_processed_share_transfer_forms' => false,
'has_processed_dcs_opt_out' => false,
'clearing_house_number' => null,
'CSCS_settlement_status' => null,
'setup_fee_paid_at' => null,
]);
$goal->portfolios()->create([
'investment_service_provider_id' => InvestmentServiceProvider::factory()->create()->id,
'security_id' => $security->id,
'security_asset_category' => $security->asset_category,
'security_name' => $security->security_name,
'equity_units' => 9000,
'equity_unit_cost' => 50.0,
'equity_total_cost' => 450000.0,
'equity_market_price' => $security->market_price,
'equity_market_value' => 900000.0,
]);
$goal->setupSaving()->create([
'goal_name' => $goal->goal_name,
'amount' => 50000,
'start_date' => today(),
'savings_frequency' => 'Monthly',
]);
$signature = UploadedFile::fake()->image('signature.jpg');
$user->updateSignature($signature);
$loanDocs = [
'loanAgreement' => storage_path('/app/public/PLA'.$user->first_name.'.pdf'),
'lienDocuments' => storage_path('/app/public/PLL'.$user->first_name.'.pdf')
];
$request = [
'amount' => 250000.00,
'bank_code' => $bank->bank_code,
'account_number' => '1234567890',
'account_name' => $user->first_name.' '.$user->last_name,
'terms' => true,
'loan_type' => 'Personal Loan'
];
$this->partialMock(LoansController::class, function (MockInterface $mock) use ($loanDocs) {
$mock->shouldReceive('processLoanDocumentation')
->withAnyArgs()
->once()
->andReturn($loanDocs);
// Allow unexpected methods like getMiddleware
$mock->makePartial();
});
$requiredData = [];
Http::fake([
$loanProvider->url.$loanProvider->endpoints['loanApplication'] => Http::response([
'status' => 'success',
'message' => 'test message',
'data' => [
'status' => 'approved',
'meta' => [
'user_id' => $user->id,
'loanProvider_id' => $loanProvider->id,
],
],
], 200),
config('services.nibss.url').'/v3/transfers' => function ($request) use ($user, $loanProvider,
&$requiredData) {
$requestBody = json_decode($request->body(), true);
$ref = $requestBody['tx_ref'];
$loanId = $requestBody['meta']['loan_id'];
$amount = $requestBody['amount'];
$requiredData['ref'] = $ref;
$requiredData['loan_id'] = $loanId;
$requiredData['amount'] = $amount;
return Http::response([
'status' => 'success',
'message' => 'test message',
'data' => [
'tx_ref' => $ref,
'amount' => $amount,
'meta' => [
'user_id' => $user->id,
'loan_id' => $loanId,
'loanProvider_id' => $loanProvider->id,
'transfer_description' => 'Loan Disbursement',
],
],
], 200);
},
config('services.nibss.url').'/v3/transactions/*' => function () use (&$requiredData, $loanProvider, $user) {
return Http::response([
'status' => 'success',
'message' => 'test message',
'data' => [
'status' => 'successful',
'amount' => $requiredData['amount'],
'tx_ref' => $requiredData['ref'],
'payment_type' => 'card',
'created_at' => now(),
'ip_address' => null,
'processor_response' => 'successful',
'card' => [
'reusable' => true,
'exp_year' => '2026',
'exp_month' => '07',
'token' => $loanProvider->auth_code,
],
'meta' => [
'benefit_id' => null,
'goal_id' => null,
'user_id' => $user->id,
'setupBenefit_id' => null,
'investment_service_provider_id' => null,
'loan_id' => $requiredData['loan_id'],
'loanProvider_id' => $loanProvider->id,
'reward_id' => null,
'providerAuth_id' => null,
'rewardProvider' => null,
'transfer_description' => 'Loan Disbursement',
'bill_type' => null,
],
],
], 200);
},
]);
Notification::fake();
Event::fake();
// Act
$response = $this->actingAs($user)->post(route('loans.process-application', $user), $request);
$loan = Loan::findOrFail($requiredData['loan_id']);
// Assert
$recorded = Http::recorded();
expect($recorded[0][0]->url())->toBe($loanProvider->api_url.$loanProvider->endpoints['loanApplication']);
expect($recorded[1][0]->url())->toBe(config('services.nibss.url').'/v3/transfers');
expect($recorded[2][0]->url())->toBe(config('services.nibss.url').'/v3/transactions/'.$requiredData['ref'].'/verify');
Http::assertSentCount(3);
$this->assertDatabaseCount('loans', 1);
$this->assertDatabaseHas('loans', [
'user_id' => $user->id,
'loan_provider_id' => $loanProvider->id,
'principal_amount' => $request['amount'],
'loan_status' => 'running',
'loan_start_date' => $loan->fresh()->loan_start_date,
]);
$this->assertDatabaseCount('setup_loan_repayments', 1);
$this->assertDatabaseHas('setup_loan_repayments', [ "direct_repayment_start_date" => null]);
$this->assertDatabaseCount('transfers', 1);
$this->assertDatabaseHas('transfers', [
'model_id' => $loan->id,
'amount' => $request['amount'],
'transfer_status' => 'successful',
'transfer_description' => 'Loan Disbursement',
]);
Event::assertDispatched(SuccessfulTransfer::class);
Notification::assertSentTo($loanProvider, SuccessfulLoanDisbursement::class);
Notification::assertNotSentTo($loanProvider, LoanDocumentation::class);
Notification::assertSentTo($user, LoanDisbursementNotification::class);
Event::assertDispatched(PortfolioManagersNotice::class);
$response->assertStatus(302);
expect($loan->fresh()->loan_status)->toBe('running');
expect($loan->fresh()->loan_start_date)->toEqual(today());
$response->assertSessionHas('flash.banner', 'Your loan application was successful. You will receive funds shortly!');
$response->assertSessionHas('flash.bannerStyle', 'success');
$response->assertRedirectToRoute('loans.index');
$this->followRedirects($response) // works but method isn't documented
->assertInertia(fn (AssertableInertia $page) => $page
->component('Loans/AllLoans')
);
});