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:
2026-02-08 21:38:20 +01:00
parent 4005c70082
commit 9ccad77bf0
29 changed files with 1706 additions and 33 deletions

View File

@@ -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));
}
}