Files
Classeo/backend/tests/Unit/Shared/Infrastructure/Messenger/DeadLetterAlertHandlerTest.php
Mathias STRASSER 9ccad77bf0 feat: Messaging asynchrone fiable avec retry, dead-letter et métriques
Les événements métier (emails d'invitation, reset password, activation)
bloquaient la réponse API en étant traités de manière synchrone. Ce commit
route ces événements vers un transport AMQP asynchrone avec un worker
dédié, garantissant des réponses API rapides et une gestion robuste des
échecs.

Le retry utilise une stratégie Fibonacci (1s, 1s, 2s, 3s, 5s, 8s, 13s)
qui offre un bon compromis entre réactivité et protection des services
externes. Les messages qui épuisent leurs tentatives arrivent dans une
dead-letter queue Doctrine avec alerte email à l'admin.

La commande console CreateTestActivationTokenCommand détecte désormais
les comptes déjà actifs et génère un token de réinitialisation de mot
de passe au lieu d'un token d'activation, évitant une erreur bloquante
lors de la ré-invitation par un admin.
2026-02-08 21:38:20 +01:00

125 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
use App\Shared\Infrastructure\Messenger\DeadLetterAlertHandler;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use RuntimeException;
use stdClass;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
use Symfony\Component\Mime\Email;
use Twig\Environment;
final class DeadLetterAlertHandlerTest extends TestCase
{
#[Test]
public function itSendsAlertEmailWhenMessageWillNotRetry(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$logger = $this->createMock(LoggerInterface::class);
$twig->expects($this->once())
->method('render')
->with('emails/dead_letter_alert.html.twig', $this->callback(
static fn (array $params): bool => $params['eventType'] === stdClass::class
&& $params['retryCount'] === 7
&& $params['lastError'] === 'SMTP timeout'
&& $params['transportName'] === 'async',
))
->willReturn('<html>alert</html>');
$mailer->expects($this->once())
->method('send')
->with($this->callback(
static fn (Email $email): bool => $email->getTo()[0]->getAddress() === 'admin@classeo.fr'
&& str_contains($email->getSubject() ?? '', 'dead-letter'),
));
$handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr');
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]);
$event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('SMTP timeout'));
$handler($event);
}
#[Test]
public function itDoesNotSendAlertWhenMessageWillRetry(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$logger = $this->createMock(LoggerInterface::class);
$mailer->expects($this->never())->method('send');
$twig->expects($this->never())->method('render');
$handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr');
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(1)]);
$event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('SMTP timeout'));
$event->setForRetry();
$handler($event);
}
#[Test]
public function itLogsErrorWhenMailerFailsInsteadOfThrowing(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$logger = $this->createMock(LoggerInterface::class);
$twig->method('render')->willReturn('<html>alert</html>');
$mailer->method('send')->willThrowException(new RuntimeException('SMTP connection refused'));
$logger->expects($this->once())
->method('error')
->with('Failed to send dead-letter alert email.', $this->callback(
static fn (array $context): bool => $context['error'] === 'SMTP connection refused'
&& $context['messageType'] === stdClass::class,
));
$handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr');
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]);
$event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException('Original error'));
// Should NOT throw - the mailer failure is caught and logged
$handler($event);
}
#[Test]
public function itTruncatesLongErrorMessages(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$logger = $this->createMock(LoggerInterface::class);
$longError = str_repeat('x', 1000);
$twig->expects($this->once())
->method('render')
->with('emails/dead_letter_alert.html.twig', $this->callback(
static fn (array $params): bool => mb_strlen($params['lastError']) <= 500,
))
->willReturn('<html>alert</html>');
$mailer->method('send');
$handler = new DeadLetterAlertHandler($mailer, $twig, $logger, 'admin@classeo.fr');
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]);
$event = new WorkerMessageFailedEvent($envelope, 'async', new RuntimeException($longError));
$handler($event);
}
}