PHPUnit strict mock validation rejects stdClass as return value for retrievePaymentIntent which declares Stripe\PaymentIntent return type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
397 lines
14 KiB
PHP
397 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Command;
|
|
|
|
use App\Command\StripeSyncCommand;
|
|
use App\Entity\BilletBuyer;
|
|
use App\Entity\Event;
|
|
use App\Entity\User;
|
|
use App\Service\AuditService;
|
|
use App\Service\BilletOrderService;
|
|
use App\Service\MailerService;
|
|
use App\Service\StripeService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\EntityRepository;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Console\Application;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
|
|
class StripeSyncCommandTest extends TestCase
|
|
{
|
|
private StripeService $stripeService;
|
|
private BilletOrderService $billetOrderService;
|
|
private MailerService $mailerService;
|
|
private AuditService $audit;
|
|
private EntityManagerInterface $em;
|
|
private EntityRepository $userRepo;
|
|
private EntityRepository $buyerRepo;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->stripeService = $this->createMock(StripeService::class);
|
|
$this->billetOrderService = $this->createMock(BilletOrderService::class);
|
|
$this->mailerService = $this->createMock(MailerService::class);
|
|
$this->audit = $this->createMock(AuditService::class);
|
|
|
|
$this->userRepo = $this->createMock(EntityRepository::class);
|
|
$this->buyerRepo = $this->createMock(EntityRepository::class);
|
|
$this->buyerRepo->method('findBy')->willReturn([]);
|
|
|
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
|
$this->em->method('getRepository')->willReturnCallback(function (string $class) {
|
|
return match ($class) {
|
|
User::class => $this->userRepo,
|
|
BilletBuyer::class => $this->buyerRepo,
|
|
default => $this->createMock(EntityRepository::class),
|
|
};
|
|
});
|
|
}
|
|
|
|
private function createOrganizer(string $stripeId, bool $charges = false, bool $payouts = false): User
|
|
{
|
|
$user = new User();
|
|
$user->setEmail('orga-'.uniqid().'@test.fr');
|
|
$user->setFirstName('Test');
|
|
$user->setLastName('Orga');
|
|
$user->setPassword('hashed');
|
|
$user->setRoles(['ROLE_ORGANIZER']);
|
|
$user->setStripeAccountId($stripeId);
|
|
$user->setStripeChargesEnabled($charges);
|
|
$user->setStripePayoutsEnabled($payouts);
|
|
$user->setCompanyName('Asso Test');
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function createPendingOrder(?string $paymentIntentId = null): BilletBuyer
|
|
{
|
|
$event = $this->createMock(Event::class);
|
|
$event->method('getTitle')->willReturn('Test Event');
|
|
$event->method('getAccount')->willReturn($this->createOrganizer('acct_org'));
|
|
|
|
$order = new BilletBuyer();
|
|
$order->setStatus(BilletBuyer::STATUS_PENDING);
|
|
$order->setStripeSessionId($paymentIntentId);
|
|
$order->setEmail('buyer@test.fr');
|
|
$order->setEvent($event);
|
|
|
|
return $order;
|
|
}
|
|
|
|
private function createCommandTester(): CommandTester
|
|
{
|
|
$command = new StripeSyncCommand(
|
|
$this->em,
|
|
$this->stripeService,
|
|
$this->billetOrderService,
|
|
$this->mailerService,
|
|
$this->audit,
|
|
);
|
|
|
|
$app = new Application();
|
|
$app->addCommand($command);
|
|
|
|
return new CommandTester($app->find('app:stripe:sync'));
|
|
}
|
|
|
|
private function setBuyerRepo(array $orders): void
|
|
{
|
|
$this->buyerRepo = $this->createMock(EntityRepository::class);
|
|
$this->buyerRepo->method('findBy')->willReturn($orders);
|
|
|
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
|
$this->em->method('getRepository')->willReturnCallback(function (string $class) {
|
|
return match ($class) {
|
|
User::class => $this->userRepo,
|
|
BilletBuyer::class => $this->buyerRepo,
|
|
default => $this->createMock(EntityRepository::class),
|
|
};
|
|
});
|
|
}
|
|
|
|
// --- Account sync tests ---
|
|
|
|
public function testSyncUpdatesStripeStatus(): void
|
|
{
|
|
$user = $this->createOrganizer('acct_123');
|
|
$this->userRepo->method('findAll')->willReturn([$user]);
|
|
|
|
$this->stripeService->method('retrieveAccountStatus')
|
|
->with('acct_123')
|
|
->willReturn(['charges_enabled' => true, 'payouts_enabled' => true]);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertTrue($user->isStripeChargesEnabled());
|
|
self::assertTrue($user->isStripePayoutsEnabled());
|
|
self::assertStringContainsString('1 synced', $tester->getDisplay());
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
}
|
|
|
|
public function testSyncDetectsChanges(): void
|
|
{
|
|
$user = $this->createOrganizer('acct_456', true, true);
|
|
$this->userRepo->method('findAll')->willReturn([$user]);
|
|
|
|
$this->stripeService->method('retrieveAccountStatus')
|
|
->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertTrue($user->isStripeChargesEnabled());
|
|
self::assertFalse($user->isStripePayoutsEnabled());
|
|
self::assertStringContainsString('UPDATED', $tester->getDisplay());
|
|
}
|
|
|
|
public function testSyncWithNoOrganizers(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('No organizers', $tester->getDisplay());
|
|
self::assertSame(0, $tester->getStatusCode());
|
|
}
|
|
|
|
public function testSyncHandlesStripeError(): void
|
|
{
|
|
$user = $this->createOrganizer('acct_bad');
|
|
$this->userRepo->method('findAll')->willReturn([$user]);
|
|
|
|
$this->stripeService->method('retrieveAccountStatus')
|
|
->willThrowException(new \RuntimeException('Account not found'));
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('1 error', $tester->getDisplay());
|
|
self::assertSame(1, $tester->getStatusCode());
|
|
}
|
|
|
|
public function testSyncSkipsOrganizersWithoutStripeAccount(): void
|
|
{
|
|
$userWithStripe = $this->createOrganizer('acct_ok');
|
|
|
|
$userWithoutStripe = new User();
|
|
$userWithoutStripe->setEmail('no-stripe@test.fr');
|
|
$userWithoutStripe->setFirstName('No');
|
|
$userWithoutStripe->setLastName('Stripe');
|
|
$userWithoutStripe->setPassword('hashed');
|
|
$userWithoutStripe->setRoles(['ROLE_ORGANIZER']);
|
|
|
|
$this->userRepo->method('findAll')->willReturn([$userWithStripe, $userWithoutStripe]);
|
|
|
|
$this->stripeService->method('retrieveAccountStatus')
|
|
->willReturn(['charges_enabled' => true, 'payouts_enabled' => false]);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('Syncing 1 organizer', $tester->getDisplay());
|
|
self::assertStringContainsString('1 synced', $tester->getDisplay());
|
|
self::assertTrue($userWithStripe->isStripeChargesEnabled());
|
|
self::assertFalse($userWithStripe->isStripePayoutsEnabled());
|
|
}
|
|
|
|
// --- Pending orders sync tests ---
|
|
|
|
public function testNoPendingOrders(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('No pending orders', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderSucceeded(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder('pi_succeeded');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_succeeded',
|
|
'status' => 'succeeded',
|
|
'amount' => 5000,
|
|
'metadata' => [],
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')
|
|
->with('pi_succeeded')
|
|
->willReturn($paymentIntent);
|
|
|
|
$this->billetOrderService->expects(self::once())->method('generateOrderTickets')->with($order);
|
|
$this->billetOrderService->expects(self::once())->method('generateAndSendTickets')->with($order);
|
|
$this->billetOrderService->expects(self::once())->method('notifyOrganizer')->with($order);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('PAID', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderSucceededWithDebtOrganizer(): void
|
|
{
|
|
$organizer = $this->createOrganizer('acct_debt');
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
$this->userRepo->method('find')->with(42)->willReturn($organizer);
|
|
|
|
$order = $this->createPendingOrder('pi_debt');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_debt',
|
|
'status' => 'succeeded',
|
|
'amount' => 3000,
|
|
'metadata' => ['debt_organizer_id' => '42'],
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('PAID', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderCanceled(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder('pi_canceled');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_canceled',
|
|
'status' => 'canceled',
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
|
|
|
|
$this->audit->expects(self::once())->method('log')
|
|
->with('payment_cancelled_sync', 'BilletBuyer', self::anything(), self::anything());
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
|
|
self::assertStringContainsString('CANCELLED', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderFailed(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder('pi_failed');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_failed',
|
|
'status' => 'requires_payment_method',
|
|
'last_payment_error' => ['message' => 'Card declined'],
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
|
|
|
|
$this->audit->expects(self::once())->method('log')
|
|
->with('payment_failed_sync', 'BilletBuyer', self::anything(), self::anything());
|
|
$this->mailerService->expects(self::once())->method('sendEmail');
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
|
|
self::assertStringContainsString('FAILED', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderSkippedWithoutPaymentIntentId(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder(null);
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$this->stripeService->expects(self::never())->method('retrievePaymentIntent');
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('SKIP', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderStillPending(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder('pi_processing');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_processing',
|
|
'status' => 'processing',
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertSame(BilletBuyer::STATUS_PENDING, $order->getStatus());
|
|
self::assertStringContainsString('PENDING', $tester->getDisplay());
|
|
}
|
|
|
|
public function testPendingOrderStripeApiError(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$order = $this->createPendingOrder('pi_error');
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')
|
|
->willThrowException(new \RuntimeException('Stripe API error'));
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertStringContainsString('ERROR', $tester->getDisplay());
|
|
self::assertSame(1, $tester->getStatusCode());
|
|
}
|
|
|
|
public function testPendingOrderFailedWithoutEmail(): void
|
|
{
|
|
$this->userRepo->method('findAll')->willReturn([]);
|
|
|
|
$event = $this->createMock(Event::class);
|
|
$event->method('getTitle')->willReturn('Test Event');
|
|
|
|
$order = new BilletBuyer();
|
|
$order->setStatus(BilletBuyer::STATUS_PENDING);
|
|
$order->setStripeSessionId('pi_no_email');
|
|
$order->setEvent($event);
|
|
|
|
$this->setBuyerRepo([$order]);
|
|
|
|
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
|
|
'id' => 'pi_no_email',
|
|
'status' => 'requires_payment_method',
|
|
'last_payment_error' => null,
|
|
]);
|
|
|
|
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
|
|
|
|
$this->mailerService->expects(self::never())->method('sendEmail');
|
|
|
|
$tester = $this->createCommandTester();
|
|
$tester->execute([]);
|
|
|
|
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
|
|
self::assertStringContainsString('FAILED', $tester->getDisplay());
|
|
}
|
|
}
|