File: /home/xedaptot/ai.naniguide.com/tests/Unit/Cashier/PaymentIntentFactoryTest.php
<?php
namespace Tests\Unit\Cashier;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Model\Invoice;
use App\Model\PaymentGateway;
use App\Model\PaymentIntent as PaymentIntentModel;
use App\Cashier\Factories\PaymentIntentFactory;
use App\Cashier\DTO\PaymentIntent as PaymentIntentDto;
class PaymentIntentFactoryTest extends TestCase
{
use DatabaseTransactions;
private function factory(): PaymentIntentFactory
{
return app(PaymentIntentFactory::class);
}
public function test_create_from_invoice_inserts_row_with_pending_status()
{
$invoice = Invoice::factory()->create();
$gateway = PaymentGateway::factory()->stripe()->create();
$countBefore = PaymentIntentModel::count();
$dto = $this->factory()->createFromInvoice($invoice, $gateway);
$countAfter = PaymentIntentModel::count();
$this->assertSame($countBefore + 1, $countAfter);
$row = PaymentIntentModel::where('uid', $dto->uid)->first();
$this->assertNotNull($row);
$this->assertSame(PaymentIntentModel::STATUS_PENDING, $row->status);
$this->assertSame($invoice->id, $row->invoice_id);
$this->assertSame($gateway->id, $row->payment_gateway_id);
}
public function test_create_from_invoice_returns_dto_with_matching_fields()
{
$invoice = Invoice::factory()->create();
$gateway = PaymentGateway::factory()->stripe()->create();
$dto = $this->factory()->createFromInvoice($invoice, $gateway);
$this->assertInstanceOf(PaymentIntentDto::class, $dto);
$this->assertSame($gateway->uid, $dto->paymentGatewayId);
$this->assertNull($dto->subscription);
$this->assertFalse($dto->isSubscription());
}
public function test_sub_spec_hydrates_subscription_in_dto_and_metadata()
{
$invoice = Invoice::factory()->create();
$gateway = PaymentGateway::factory()->stripeSubscription()->create();
$dto = $this->factory()->createFromInvoice(
$invoice,
$gateway,
['remote_plan_id' => 'price_xyz']
);
$this->assertTrue($dto->isSubscription());
$this->assertSame('price_xyz', $dto->subscription->remotePlanId);
$row = PaymentIntentModel::where('uid', $dto->uid)->first();
$this->assertSame('price_xyz', $row->metadata['remote_plan_id']);
}
public function test_concurrent_create_returns_existing_intent_when_locked()
{
// Race scenario: user clicks Pay in 2 tabs. Without the lock both calls
// would create parallel intents → potential double-charge. With the lock,
// the second call sees invoice.processing_intent_id is set and reuses it.
$invoice = Invoice::factory()->create();
$gateway = PaymentGateway::factory()->stripe()->create();
$dto1 = $this->factory()->createFromInvoice($invoice, $gateway);
$dto2 = $this->factory()->createFromInvoice($invoice, $gateway);
$this->assertSame($dto1->uid, $dto2->uid, 'Second concurrent call must reuse the in-flight intent');
$this->assertSame(
1,
PaymentIntentModel::where('invoice_id', $invoice->id)->count(),
'Lock prevents parallel intents on same invoice + gateway'
);
$invoice->refresh();
$this->assertNotNull($invoice->processing_intent_id, 'Lock pointer must be set');
}
public function test_create_with_different_gateway_supersedes_old_intent()
{
// User starts with Stripe, then switches to StripeSubscription (different gateway).
// Old intent gets cancelled, new intent is created.
$invoice = Invoice::factory()->create();
$stripeGw = PaymentGateway::factory()->stripe()->create();
$subGw = PaymentGateway::factory()->stripeSubscription()->create();
$dto1 = $this->factory()->createFromInvoice($invoice, $stripeGw);
$dto2 = $this->factory()->createFromInvoice($invoice, $subGw, ['remote_plan_id' => 'price_xyz']);
$this->assertNotSame($dto1->uid, $dto2->uid);
$old = PaymentIntentModel::where('uid', $dto1->uid)->firstOrFail();
$this->assertSame(PaymentIntentModel::STATUS_CANCELLED, $old->status, 'Old intent on different gateway must be cancelled');
$invoice->refresh();
$new = PaymentIntentModel::where('uid', $dto2->uid)->firstOrFail();
$this->assertSame($new->id, $invoice->processing_intent_id, 'Lock pointer must point to the new intent');
}
public function test_create_after_terminal_intent_creates_fresh_one()
{
// After lock is released (intent reaches terminal), next attempt creates a new intent.
$invoice = Invoice::factory()->create();
$gateway = PaymentGateway::factory()->stripe()->create();
$dto1 = $this->factory()->createFromInvoice($invoice, $gateway);
// Simulate handler releasing the lock on terminal transition
$first = PaymentIntentModel::where('uid', $dto1->uid)->firstOrFail();
$first->update(['status' => PaymentIntentModel::STATUS_FAILED]);
$invoice->update(['processing_intent_id' => null]);
$dto2 = $this->factory()->createFromInvoice($invoice, $gateway);
$this->assertNotSame($dto1->uid, $dto2->uid, 'After lock release, next call must create a fresh intent');
$this->assertSame(
2,
PaymentIntentModel::where('invoice_id', $invoice->id)->count(),
'Two distinct intents — one failed, one new attempt'
);
}
}