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.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Messenger\FibonacciRetryStrategy;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
use Symfony\Component\Mailer\Exception\TransportException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Stamp\RedeliveryStamp;
|
||||
use Twig\Error\LoaderError;
|
||||
|
||||
final class FibonacciRetryStrategyTest extends TestCase
|
||||
{
|
||||
private FibonacciRetryStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->strategy = new FibonacciRetryStrategy();
|
||||
}
|
||||
|
||||
public function testIsRetryableForTransientError(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$throwable = new TransportException('SMTP timeout');
|
||||
|
||||
$this->assertTrue($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsNotRetryableForPermanentError(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$throwable = new LoaderError('Template not found');
|
||||
|
||||
$this->assertFalse($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsNotRetryableForInvalidArgumentException(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$throwable = new InvalidArgumentException('Invalid email');
|
||||
|
||||
$this->assertFalse($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsNotRetryableForLogicException(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$throwable = new LogicException('Logic error');
|
||||
|
||||
$this->assertFalse($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsNotRetryableWhenMaxRetriesReached(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(7)]);
|
||||
$throwable = new TransportException('SMTP timeout');
|
||||
|
||||
$this->assertFalse($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsRetryableAtRetry6(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass(), [new RedeliveryStamp(6)]);
|
||||
$throwable = new TransportException('SMTP timeout');
|
||||
|
||||
$this->assertTrue($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsRetryableWithNullThrowable(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
|
||||
$this->assertTrue($this->strategy->isRetryable($envelope, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider fibonacciSequenceProvider
|
||||
*/
|
||||
public function testFibonacciWaitingTimeSequence(int $retryCount, int $expectedDelayMs): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
if ($retryCount > 0) {
|
||||
$envelope = $envelope->with(new RedeliveryStamp($retryCount));
|
||||
}
|
||||
|
||||
$this->assertSame($expectedDelayMs, $this->strategy->getWaitingTime($envelope));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{int, int}>
|
||||
*/
|
||||
public static function fibonacciSequenceProvider(): iterable
|
||||
{
|
||||
yield 'retry 0 → 1s' => [0, 1000];
|
||||
yield 'retry 1 → 1s' => [1, 1000];
|
||||
yield 'retry 2 → 2s' => [2, 2000];
|
||||
yield 'retry 3 → 3s' => [3, 3000];
|
||||
yield 'retry 4 → 5s' => [4, 5000];
|
||||
yield 'retry 5 → 8s' => [5, 8000];
|
||||
yield 'retry 6 → 13s' => [6, 13000];
|
||||
}
|
||||
|
||||
public function testIsRetryableWithRuntimeException(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$throwable = new RuntimeException('Connection refused');
|
||||
|
||||
$this->assertTrue($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsRetryableWithWrappedTransientError(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$inner = new TransportException('SMTP timeout');
|
||||
$throwable = new RuntimeException('Wrapped', 0, $inner);
|
||||
|
||||
$this->assertTrue($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
|
||||
public function testIsNotRetryableWithWrappedPermanentError(): void
|
||||
{
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$inner = new LoaderError('Template not found');
|
||||
$throwable = new RuntimeException('Wrapped', 0, $inner);
|
||||
|
||||
$this->assertFalse($this->strategy->isRetryable($envelope, $throwable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Counter;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
use Symfony\Component\Messenger\Stamp\HandledStamp;
|
||||
|
||||
final class MessengerMetricsMiddlewareTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itIncrementsHandledCounterOnSuccess(): void
|
||||
{
|
||||
$handledCounter = $this->createMock(Counter::class);
|
||||
$failedCounter = $this->createMock(Counter::class);
|
||||
|
||||
// Label is now the message class name, not the handler name
|
||||
$handledCounter->expects($this->once())
|
||||
->method('inc')
|
||||
->with(['stdClass']);
|
||||
|
||||
$failedCounter->expects($this->never())->method('inc');
|
||||
|
||||
$registry = $this->createRegistryMock($handledCounter, $failedCounter);
|
||||
$middleware = new MessengerMetricsMiddleware($registry);
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$returnedEnvelope = $envelope->with(new HandledStamp('result', 'App\\Test\\TestHandler'));
|
||||
|
||||
$stack = $this->createStackReturning($returnedEnvelope);
|
||||
|
||||
$middleware->handle($envelope, $stack);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncrementsFailedCounterOnException(): void
|
||||
{
|
||||
$handledCounter = $this->createMock(Counter::class);
|
||||
$failedCounter = $this->createMock(Counter::class);
|
||||
|
||||
$handledCounter->expects($this->never())->method('inc');
|
||||
$failedCounter->expects($this->once())
|
||||
->method('inc')
|
||||
->with(['stdClass']);
|
||||
|
||||
$registry = $this->createRegistryMock($handledCounter, $failedCounter);
|
||||
$middleware = new MessengerMetricsMiddleware($registry);
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createStackThrowing(new RuntimeException('fail'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$middleware->handle($envelope, $stack);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotIncrementHandledWhenNoHandledStamps(): void
|
||||
{
|
||||
$handledCounter = $this->createMock(Counter::class);
|
||||
$failedCounter = $this->createMock(Counter::class);
|
||||
|
||||
$handledCounter->expects($this->never())->method('inc');
|
||||
$failedCounter->expects($this->never())->method('inc');
|
||||
|
||||
$registry = $this->createRegistryMock($handledCounter, $failedCounter);
|
||||
$middleware = new MessengerMetricsMiddleware($registry);
|
||||
|
||||
// No HandledStamp = async dispatch, no handler executed yet
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createStackReturning($envelope);
|
||||
|
||||
$middleware->handle($envelope, $stack);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSurvivesPrometheusStorageFailure(): void
|
||||
{
|
||||
$handledCounter = $this->createMock(Counter::class);
|
||||
$failedCounter = $this->createMock(Counter::class);
|
||||
|
||||
// Simulate Redis/Prometheus failure
|
||||
$handledCounter->method('inc')->willThrowException(new RuntimeException('Redis connection refused'));
|
||||
|
||||
$registry = $this->createRegistryMock($handledCounter, $failedCounter);
|
||||
$middleware = new MessengerMetricsMiddleware($registry);
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$returnedEnvelope = $envelope->with(new HandledStamp('result', 'App\\Test\\TestHandler'));
|
||||
$stack = $this->createStackReturning($returnedEnvelope);
|
||||
|
||||
// Should NOT throw - metrics failure is swallowed
|
||||
$result = $middleware->handle($envelope, $stack);
|
||||
self::assertSame($returnedEnvelope, $result);
|
||||
}
|
||||
|
||||
private function createRegistryMock(Counter $handledCounter, Counter $failedCounter): CollectorRegistry
|
||||
{
|
||||
$registry = $this->createMock(CollectorRegistry::class);
|
||||
$registry->method('getOrRegisterCounter')
|
||||
->willReturnCallback(
|
||||
static fn (string $ns, string $name): Counter => match (true) {
|
||||
str_contains($name, 'handled') => $handledCounter,
|
||||
str_contains($name, 'failed') => $failedCounter,
|
||||
},
|
||||
);
|
||||
|
||||
return $registry;
|
||||
}
|
||||
|
||||
private function createStackReturning(Envelope $envelope): StackInterface
|
||||
{
|
||||
return new class($envelope) implements StackInterface {
|
||||
public function __construct(private readonly Envelope $envelope)
|
||||
{
|
||||
}
|
||||
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class($this->envelope) implements MiddlewareInterface {
|
||||
public function __construct(private readonly Envelope $envelope)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $this->envelope;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createStackThrowing(RuntimeException $exception): StackInterface
|
||||
{
|
||||
return new class($exception) implements StackInterface {
|
||||
public function __construct(private readonly RuntimeException $exception)
|
||||
{
|
||||
}
|
||||
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class($this->exception) implements MiddlewareInterface {
|
||||
public function __construct(private readonly RuntimeException $exception)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
throw $this->exception;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user