File: /home/xedaptot/be.naniguide.com/tests/Unit/Cashier/CheckoutHandlerTest.php
<?php
namespace Tests\Unit\Cashier;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Mockery;
use App\Model\PaymentIntent as PaymentIntentModel;
use App\Cashier\Contracts\PaymentMethodInfoInterface;
use App\Library\Checkout\CheckoutHandler;
use App\Services\Subscription\SubscriptionManagementService;
class CheckoutHandlerTest extends TestCase
{
use DatabaseTransactions;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function handler(): CheckoutHandler
{
// Stub downstream services so we only test the handler's intent row updates.
$this->app->instance(
SubscriptionManagementService::class,
Mockery::mock(SubscriptionManagementService::class)->shouldIgnoreMissing()
);
// FulfillmentService stub — flip status only, skip real order dispatch.
$stub = new class extends \App\Library\OrderFulfillment\FulfillmentService {
public function __construct() {}
public function fulfillOrder(\App\Model\Order $order): void {}
public function failOrder(\App\Model\Order $order, string $error): void {}
public function cancelOrder(\App\Model\Order $order): void {}
};
$this->app->instance(\App\Library\OrderFulfillment\FulfillmentService::class, $stub);
return app(CheckoutHandler::class);
}
private function fakePmInfo(): PaymentMethodInfoInterface
{
$mock = Mockery::mock(PaymentMethodInfoInterface::class);
$mock->shouldReceive('getAutoBillingData')->andReturn([]);
$mock->shouldReceive('getPaymentGatewayUid')->andReturn('gw_uid');
return $mock;
}
public function test_find_intent_returns_null_for_unknown_uid()
{
$this->assertNull($this->handler()->findIntent('does-not-exist'));
}
public function test_find_intent_returns_dto_for_known_uid()
{
// Capability-token model: any caller with a valid uid gets the DTO.
// Security covered by: (a) UUIDv4 entropy, (b) status state machine,
// (c) server-stored remote_reference_id, (d) rate limit on pay routes.
$pi = PaymentIntentModel::factory()->create();
$dto = $this->handler()->findIntent($pi->uid);
$this->assertNotNull($dto);
$this->assertSame($pi->uid, $dto->uid);
}
public function test_on_payment_success_transitions_intent_to_succeeded()
{
$pi = PaymentIntentModel::factory()->create();
$dto = $pi->toDto();
$this->handler()->onPaymentSuccess($dto, $this->fakePmInfo(), 'pi_remote_xyz');
$pi->refresh();
$this->assertSame(PaymentIntentModel::STATUS_SUCCEEDED, $pi->status);
$this->assertSame('pi_remote_xyz', $pi->remote_reference_id);
$this->assertNotNull($pi->succeeded_at);
}
public function test_on_payment_failed_transitions_intent_and_records_reason()
{
$pi = PaymentIntentModel::factory()->create();
$dto = $pi->toDto();
$this->handler()->onPaymentFailed($dto, $this->fakePmInfo(), 'Card declined');
$pi->refresh();
$this->assertSame(PaymentIntentModel::STATUS_FAILED, $pi->status);
$this->assertSame('Card declined', $pi->failed_reason);
}
public function test_on_payment_requires_auth_locks_remote_ref_and_stores_client_secret()
{
$pi = PaymentIntentModel::factory()->create();
$dto = $pi->toDto();
$this->handler()->onPaymentRequiresAuth($dto, 'pi_secret_abc', 'pi_remote_abc');
$pi->refresh();
$this->assertSame(PaymentIntentModel::STATUS_REQUIRES_ACTION, $pi->status);
$this->assertSame('pi_remote_abc', $pi->remote_reference_id);
$this->assertSame('pi_secret_abc', $pi->metadata['client_secret']);
}
public function test_on_subscription_requires_auth_stores_sub_id_in_intent()
{
// Security-critical: remote_subscription_id must be server-stored, not trusted from client
$pi = PaymentIntentModel::factory()->forSubscription('price_xyz')->create();
$dto = $pi->toDto();
$this->handler()->onSubscriptionRequiresAuth($dto, 'pi_secret_xyz', 'sub_server_stored');
$pi->refresh();
$this->assertSame('sub_server_stored', $pi->remote_reference_id);
$this->assertSame('pi_secret_xyz', $pi->metadata['client_secret']);
$this->assertSame('price_xyz', $pi->metadata['remote_plan_id'], 'existing metadata preserved');
}
public function test_on_subscription_created_transitions_to_succeeded_and_clears_client_secret()
{
$pi = PaymentIntentModel::factory()->requiresAction()->forSubscription('price_xyz')->create();
$dto = $pi->toDto();
$this->handler()->onSubscriptionCreated($dto, [
'remote_subscription_id' => 'sub_abc',
'remote_customer_id' => 'cus_xyz',
'status' => 'active',
'current_period_end' => 1735689600,
'payment_method_data' => ['card_type' => 'Visa', 'last_4' => '4242'],
]);
$pi->refresh();
$this->assertSame(PaymentIntentModel::STATUS_SUCCEEDED, $pi->status);
$this->assertSame('sub_abc', $pi->remote_reference_id);
$this->assertNotNull($pi->succeeded_at);
$this->assertSame('cus_xyz', $pi->metadata['remote_customer_id']);
$this->assertSame(1735689600, $pi->metadata['current_period_end']);
// Security: client_secret stripped on terminal state
$this->assertArrayNotHasKey('client_secret', $pi->metadata);
}
public function test_on_payment_success_strips_client_secret_from_metadata()
{
$pi = PaymentIntentModel::factory()->requiresAction()->create();
// requiresAction factory seeds metadata.client_secret
$this->assertArrayHasKey('client_secret', $pi->metadata);
$this->handler()->onPaymentSuccess($pi->toDto(), $this->fakePmInfo(), 'pi_done');
$pi->refresh();
$this->assertArrayNotHasKey(
'client_secret',
$pi->metadata ?? [],
'client_secret must be cleared on terminal state to reduce blast radius'
);
}
public function test_on_payment_success_releases_invoice_processing_lock()
{
// The lock (invoice.processing_intent_id) must be cleared on terminal transitions
// so a subsequent attempt can create a fresh intent.
$pi = PaymentIntentModel::factory()->create();
$pi->invoice->update(['processing_intent_id' => $pi->id]);
$this->handler()->onPaymentSuccess($pi->toDto(), $this->fakePmInfo(), 'pi_done');
$pi->invoice->refresh();
$this->assertNull(
$pi->invoice->processing_intent_id,
'Lock pointer must be cleared after success'
);
}
public function test_on_payment_failed_releases_invoice_processing_lock()
{
$pi = PaymentIntentModel::factory()->create();
$pi->invoice->update(['processing_intent_id' => $pi->id]);
$this->handler()->onPaymentFailed($pi->toDto(), $this->fakePmInfo(), 'declined');
$pi->invoice->refresh();
$this->assertNull($pi->invoice->processing_intent_id);
}
public function test_on_subscription_created_releases_invoice_processing_lock()
{
$pi = PaymentIntentModel::factory()->forSubscription('price_xyz')->create();
$pi->invoice->update(['processing_intent_id' => $pi->id]);
$this->handler()->onSubscriptionCreated($pi->toDto(), [
'remote_subscription_id' => 'sub_abc',
'remote_customer_id' => 'cus_xyz',
'status' => 'active',
'current_period_end' => 1735689600,
'payment_method_data' => [],
]);
$pi->invoice->refresh();
$this->assertNull($pi->invoice->processing_intent_id);
}
public function test_on_offline_claim_received_transitions_to_awaiting_admin_approval()
{
$pi = PaymentIntentModel::factory()->create();
$pi->invoice->update(['processing_intent_id' => $pi->id]);
$this->handler()->onOfflineClaimReceived($pi->toDto());
$pi->refresh();
$this->assertSame(
PaymentIntentModel::STATUS_AWAITING_ADMIN_APPROVAL,
$pi->status,
'Status flips from pending → awaiting_admin_approval — admin sees this in queue'
);
$this->assertArrayHasKey('claimed_at', $pi->metadata, 'Metadata records when user claimed (audit timestamp)');
$pi->invoice->refresh();
$this->assertSame($pi->id, $pi->invoice->processing_intent_id, 'Lock stays held until terminal');
}
public function test_on_payment_requires_auth_does_not_release_lock()
{
// requires_action is in-flight, not terminal. Lock must stay set.
$pi = PaymentIntentModel::factory()->create();
$pi->invoice->update(['processing_intent_id' => $pi->id]);
$this->handler()->onPaymentRequiresAuth($pi->toDto(), 'pi_secret', 'pi_remote');
$pi->invoice->refresh();
$this->assertSame($pi->id, $pi->invoice->processing_intent_id, 'Lock must stay set during 3DS challenge');
}
}