Add comprehensive StripeWebhookController tests and fix payout creation

Tests (16 total):
- Valid/invalid signature
- v2.core.account.created/updated/closed status updates
- Account status with unknown user and missing related_object
- Merchant capability card_payments active
- Recipient capability payouts active
- Both capabilities active sets status to 'active'
- Capability update with unknown user
- Payout created with email notification
- Payout paid with PDF attachment
- Payout updated on existing payout
- Payout with missing payout ID
- Payout with unknown user (no organizer found)

Fix: skip payout creation when organizer not found instead of persisting
with null organizer_id (NOT NULL constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-20 08:45:48 +01:00
parent 1bffb33c3b
commit afbde944e1
2 changed files with 406 additions and 7 deletions

View File

@@ -111,16 +111,16 @@ class StripeWebhookController extends AbstractController
$user = null;
if (!$payout) {
$user = $accountId ? $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]) : null;
if (!$user) {
return;
}
$payout = new Payout();
$payout->setStripePayoutId($payoutId);
$payout->setStripeAccountId($accountId);
if ($accountId) {
$user = $em->getRepository(User::class)->findOneBy(['stripeAccountId' => $accountId]);
if ($user) {
$payout->setOrganizer($user);
}
}
$payout->setOrganizer($user);
$em->persist($payout);
} else {

View File

@@ -2,8 +2,14 @@
namespace App\Tests\Controller;
use App\Entity\Payout;
use App\Entity\User;
use App\Service\MailerService;
use App\Service\PayoutPdfService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Event;
use Stripe\StripeObject;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class StripeWebhookControllerTest extends WebTestCase
@@ -38,4 +44,397 @@ class StripeWebhookControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(400);
}
public function testAccountCreatedSetsStartedStatus(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_created');
$event = new Event();
$event->type = 'v2.core.account.created';
$this->mockStripe($client, $event);
$payload = json_encode(['related_object' => ['id' => 'acct_created']]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_created']);
self::assertSame('started', $updated->getStripeStatus());
}
public function testAccountClosedDisablesFlags(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_closed');
$user->setStripeChargesEnabled(true);
$user->setStripePayoutsEnabled(true);
$em->flush();
$event = new Event();
$event->type = 'v2.core.account.closed';
$this->mockStripe($client, $event);
$payload = json_encode(['related_object' => ['id' => 'acct_closed']]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_closed']);
self::assertSame('closed', $updated->getStripeStatus());
self::assertFalse($updated->isStripeChargesEnabled());
self::assertFalse($updated->isStripePayoutsEnabled());
}
public function testAccountUpdatedSetsStatus(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$this->createOrgaUser($em, 'acct_upd');
$event = new Event();
$event->type = 'v2.core.account.updated';
$this->mockStripe($client, $event);
$payload = json_encode(['related_object' => ['id' => 'acct_upd']]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_upd']);
self::assertSame('updated', $updated->getStripeStatus());
}
public function testAccountStatusUnknownUser(): void
{
$client = static::createClient();
$event = new Event();
$event->type = 'v2.core.account.created';
$this->mockStripe($client, $event);
$payload = json_encode(['related_object' => ['id' => 'acct_nonexistent']]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
}
public function testAccountStatusNoRelatedObject(): void
{
$client = static::createClient();
$event = new Event();
$event->type = 'v2.core.account.created';
$this->mockStripe($client, $event);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
}
public function testMerchantCapabilityCardPaymentsActive(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$this->createOrgaUser($em, 'acct_merchant');
$event = new Event();
$event->type = 'v2.core.account[configuration.merchant].capability_status_updated';
$this->mockStripe($client, $event);
$payload = json_encode([
'related_object' => ['id' => 'acct_merchant'],
'changes' => ['after' => ['configuration' => [
'merchant' => ['capabilities' => [
'card_payments' => ['status' => 'active'],
]],
]]],
]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_merchant']);
self::assertTrue($updated->isStripeChargesEnabled());
}
public function testRecipientCapabilityPayoutsActive(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$this->createOrgaUser($em, 'acct_recipient');
$event = new Event();
$event->type = 'v2.core.account[configuration.recipient].capability_status_updated';
$this->mockStripe($client, $event);
$payload = json_encode([
'related_object' => ['id' => 'acct_recipient'],
'changes' => ['after' => ['configuration' => [
'recipient' => ['capabilities' => [
'stripe_balance' => ['payouts' => ['status' => 'active']],
]],
]]],
]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_recipient']);
self::assertTrue($updated->isStripePayoutsEnabled());
}
public function testCapabilityBothActiveSetStatusActive(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_both');
$user->setStripeChargesEnabled(true);
$em->flush();
$event = new Event();
$event->type = 'v2.core.account[configuration.merchant].capability_status_updated';
$this->mockStripe($client, $event);
$payload = json_encode([
'related_object' => ['id' => 'acct_both'],
'changes' => ['after' => ['configuration' => [
'merchant' => ['capabilities' => [
'stripe_balance' => ['payouts' => ['status' => 'active']],
]],
]]],
]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(User::class)->findOneBy(['stripeAccountId' => 'acct_both']);
self::assertSame('active', $updated->getStripeStatus());
}
public function testCapabilityUnknownUser(): void
{
$client = static::createClient();
$event = new Event();
$event->type = 'v2.core.account[configuration.merchant].capability_status_updated';
$this->mockStripe($client, $event);
$payload = json_encode([
'related_object' => ['id' => 'acct_ghost'],
'changes' => ['after' => ['configuration' => []]],
]);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], $payload);
self::assertResponseIsSuccessful();
}
public function testPayoutCreatedSavesAndSendsEmail(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_payout');
$event = Event::constructFrom([
'type' => 'payout.created',
'account' => 'acct_payout',
'data' => ['object' => [
'id' => 'po_test_wh_'.uniqid(),
'status' => 'pending',
'amount' => 5000,
'currency' => 'eur',
'destination' => 'ba_xxx',
]],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$mailer = $this->createMock(MailerService::class);
$mailer->expects(self::once())->method('sendEmail');
static::getContainer()->set(MailerService::class, $mailer);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
}
public function testPayoutPaidSendsEmailWithPdf(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_paid');
$payoutId = 'po_paid_'.uniqid();
$event = Event::constructFrom([
'type' => 'payout.paid',
'account' => 'acct_paid',
'data' => ['object' => [
'id' => $payoutId,
'status' => 'paid',
'amount' => 10000,
'currency' => 'eur',
'destination' => 'ba_yyy',
'arrival_date' => time() + 86400,
]],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$mailer = $this->createMock(MailerService::class);
$mailer->expects(self::once())->method('sendEmail');
static::getContainer()->set(MailerService::class, $mailer);
$pdfService = $this->createMock(PayoutPdfService::class);
$pdfService->expects(self::once())->method('generateToFile')->willReturn('/tmp/fake.pdf');
static::getContainer()->set(PayoutPdfService::class, $pdfService);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$payout = $freshEm->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $payoutId]);
self::assertNotNull($payout);
self::assertSame('paid', $payout->getStatus());
self::assertSame(10000, $payout->getAmount());
}
public function testPayoutUpdatedExistingPayout(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrgaUser($em, 'acct_upd_po');
$payout = new Payout();
$payout->setOrganizer($user);
$payout->setStripePayoutId('po_existing_'.uniqid());
$payout->setStatus('pending');
$payout->setAmount(3000);
$payout->setCurrency('eur');
$payout->setStripeAccountId('acct_upd_po');
$em->persist($payout);
$em->flush();
$event = Event::constructFrom([
'type' => 'payout.updated',
'account' => 'acct_upd_po',
'data' => ['object' => [
'id' => $payout->getStripePayoutId(),
'status' => 'in_transit',
'amount' => 3000,
'currency' => 'eur',
'destination' => 'ba_zzz',
]],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$mailer = $this->createMock(MailerService::class);
$mailer->expects(self::once())->method('sendEmail');
static::getContainer()->set(MailerService::class, $mailer);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
$freshEm = static::getContainer()->get(EntityManagerInterface::class);
$updated = $freshEm->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $payout->getStripePayoutId()]);
self::assertSame('in_transit', $updated->getStatus());
}
public function testPayoutNoPayoutId(): void
{
$client = static::createClient();
$event = Event::constructFrom([
'type' => 'payout.created',
'data' => ['object' => []],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
}
public function testPayoutNoUser(): void
{
$client = static::createClient();
$event = Event::constructFrom([
'type' => 'payout.created',
'account' => 'acct_nouser',
'data' => ['object' => [
'id' => 'po_nouser_'.uniqid(),
'status' => 'pending',
'amount' => 1000,
'currency' => 'eur',
]],
]);
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
$client->request('POST', '/stripe/webhook', [], [], ['HTTP_STRIPE_SIGNATURE' => 'v'], '{}');
self::assertResponseIsSuccessful();
}
private function createOrgaUser(EntityManagerInterface $em, string $stripeAccountId): User
{
$user = new User();
$user->setEmail('test-wh-'.uniqid().'@example.com');
$user->setFirstName('WH');
$user->setLastName('Test');
$user->setPassword('$2y$13$hashed');
$user->setRoles(['ROLE_ORGANIZER']);
$user->setStripeAccountId($stripeAccountId);
$em->persist($user);
$em->flush();
return $user;
}
private function mockStripe(\Symfony\Bundle\FrameworkBundle\KernelBrowser $client, Event $event): void
{
$stripeService = $this->createMock(StripeService::class);
$stripeService->method('verifyWebhookSignature')->willReturn($event);
static::getContainer()->set(StripeService::class, $stripeService);
}
}